From 14614652f4d644f1d74b11abd75889a3efdb19a4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 03:00:04 -0700 Subject: [PATCH 1/2] fix(web): live background-work banner no longer hides behind the update notice The composer banner stack fronted passive system banners (like the 'Server update available' notice) over the background-liveness banner, burying the only Stop affordance for settled turns behind a hover. The collapsed stack cap was also hardcoded to warning yellow, making a neutral hidden banner read as a warning sliver. Now urgent (error/warning) system banners stay in front, background liveness comes next, and calm system banners stack behind it. The cap border derives from the first hidden banner's variant. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 20 +++++++++++++++---- .../chat/ComposerBannerStack.test.tsx | 20 +++++++++++++++++-- .../components/chat/ComposerBannerStack.tsx | 17 ++++++++++++++-- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84f8b7ee509..79e3c062a4a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4372,8 +4372,12 @@ function ChatViewContent(props: ChatViewProps) { }; }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); // The stack renders items[0] front-most and tucks the rest behind hover, so - // ordering is priority: system banners, then the branch-mismatch notice, - // and the informational parked-thread banner last — it must never cover another. + // ordering is priority: urgent system banners (connection down, update + // failed), then background liveness — its Stop button is the only stop + // affordance for settled turns, so a calm "update available" notice must not + // cover it — then calm system banners, the woke and branch-mismatch + // notices, and the informational parked-thread banner last — it must never + // cover another. const parkedThreadBannerItem = useMemo(() => { if (!activeThreadSnoozed && !activeThreadSettled) { return null; @@ -4423,21 +4427,29 @@ function ChatViewContent(props: ChatViewProps) { void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { + const urgentSystemItems = systemComposerBannerItems.filter( + (item) => item.variant === "error" || item.variant === "warning", + ); + const calmSystemItems = systemComposerBannerItems.filter( + (item) => item.variant !== "error" && item.variant !== "warning", + ); const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ - ...systemComposerBannerItems, + ...urgentSystemItems, ...backgroundLivenessItems, + ...calmSystemItems, ...wokeThreadItems, ...parkedThreadItems, ]; } return [ - ...systemComposerBannerItems, + ...urgentSystemItems, ...backgroundLivenessItems, + ...calmSystemItems, ...wokeThreadItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index 1b592168c20..6eed4fb0531 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -3,9 +3,12 @@ import { describe, expect, it } from "vite-plus/test"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; -const banner = (id: string): ComposerBannerStackItem => ({ +const banner = ( + id: string, + variant: ComposerBannerStackItem["variant"] = "warning", +): ComposerBannerStackItem => ({ id, - variant: "warning", + variant, icon: , title: `${id} warning`, }); @@ -29,6 +32,19 @@ describe("ComposerBannerStack", () => { expect(markup).toContain("group-focus-within/banner-stack:visible"); }); + it("colors the collapsed stack cap by the hidden banner's variant, not a fixed warning", () => { + const neutralBehind = renderToStaticMarkup( + , + ); + expect(neutralBehind).toContain("border-border"); + expect(neutralBehind).not.toContain("border-warning/24"); + + const warningBehind = renderToStaticMarkup( + , + ); + expect(warningBehind).toContain("border-warning/24"); + }); + it("does not render an expandable region for a single banner", () => { const markup = renderToStaticMarkup(); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 548bd0f4262..6488d49cf09 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -22,6 +22,17 @@ const exitTransitionStyle = { transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`, } satisfies CSSProperties; +// The collapsed cap peeking above the front banner is the only hint that more +// banners are stacked behind it, so its border must match the severity of the +// first hidden banner — a neutral banner must not masquerade as a warning. +const stackCapBorderClass: Record = { + default: "border-border", + error: "border-destructive/24", + info: "border-info/24", + success: "border-success/24", + warning: "border-warning/24", +}; + export interface ComposerBannerStackItem { readonly id: string; readonly variant: "default" | "error" | "info" | "success" | "warning"; @@ -67,6 +78,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro const stackedItems = items.slice(1); const hasStack = stackedItems.length > 0; const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; + const firstStackedItem = stackedItems[0]; const requestDismiss = (item: ComposerBannerStackItem) => { if (!item.onDismiss || exitingItemId) { @@ -90,11 +102,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null, )} > - {showCollapsedStackCap ? ( + {showCollapsedStackCap && firstStackedItem ? (
Date: Fri, 7 Aug 2026 03:07:02 -0700 Subject: [PATCH 2/2] fix(web): keep live update progress in the urgent banner tier Bugbot caught that a running update uses the default variant, so the variant-based split stacked its progress banner behind background liveness while a failed update stayed in front. Add an explicit urgent flag for calm-styled live states (update progress, the reconnect fold) so ordering follows urgency, not visual variant. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatView.tsx | 27 ++++++++++--------- .../components/chat/ComposerBannerStack.tsx | 3 +++ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 79e3c062a4a..352e23c3b1c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1934,6 +1934,8 @@ function ChatViewContent(props: ChatViewProps) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: "default", + // Live connection status: calm styling, but it must front the stack. + urgent: true, icon: ( (() => { if (!activeThreadSnoozed && !activeThreadSettled) { return null; @@ -4427,12 +4432,10 @@ function ChatViewContent(props: ChatViewProps) { void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { - const urgentSystemItems = systemComposerBannerItems.filter( - (item) => item.variant === "error" || item.variant === "warning", - ); - const calmSystemItems = systemComposerBannerItems.filter( - (item) => item.variant !== "error" && item.variant !== "warning", - ); + const isUrgentSystemItem = (item: ComposerBannerStackItem) => + item.urgent === true || item.variant === "error" || item.variant === "warning"; + const urgentSystemItems = systemComposerBannerItems.filter(isUrgentSystemItem); + const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); const backgroundLivenessItems = backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 6488d49cf09..41a717d07ce 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -36,6 +36,9 @@ const stackCapBorderClass: Record = export interface ComposerBannerStackItem { readonly id: string; readonly variant: "default" | "error" | "info" | "success" | "warning"; + // Ordering hint for stack assemblers: front this banner even though its + // variant is calm (e.g. live update progress). The stack itself ignores it. + readonly urgent?: boolean; readonly icon: ReactNode; readonly title: ReactNode; readonly description?: ReactNode;