diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84f8b7ee509..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; @@ -4423,21 +4432,27 @@ function ChatViewContent(props: ChatViewProps) { void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { + 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]; 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..41a717d07ce 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -22,9 +22,23 @@ 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"; + // 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; @@ -67,6 +81,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 +105,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 ? (