| author | |
| committer | |
| log | 2e66b1fdfc63d39822cd58948bcf43e8fbc239b6 |
| tree | 8f7c651fa8e32c970e80870362dc84b85ca82ec5 |
| parent | 8100062a78f1e1942663a0cea9ddc42d2f835525 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>3 files changed, 55 insertions(+), 8 deletions(-)
apps/web/src/components/ChatView.tsx+19-4| ... | ... | @@ -1934,6 +1934,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 1934 | 1934 | items.push({ |
| 1935 | 1935 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, |
| 1936 | 1936 | variant: "default", |
| 1937 | // Live connection status: calm styling, but it must front the stack. | |
| 1938 | urgent: true, | |
| 1937 | 1939 | icon: ( |
| 1938 | 1940 | <span |
| 1939 | 1941 | className="size-1.5 animate-status-pulse rounded-full bg-foreground" |
| ... | ... | @@ -1988,6 +1990,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 1988 | 1990 | items.push({ |
| 1989 | 1991 | id: `server-version:${serverUpdateEnvironmentId}`, |
| 1990 | 1992 | variant: updateFailed ? "error" : "default", |
| 1993 | // A running update is live progress the user is waiting on; only the | |
| 1994 | // idle "update available" offer is calm enough to stack behind. | |
| 1995 | urgent: updateInProgress, | |
| 1991 | 1996 | // In-flight and failed states carry their own status dot inside |
| 1992 | 1997 | // ServerUpdateProgress; only the idle offer needs an icon. |
| 1993 | 1998 | icon: |
| ... | ... | @@ -4372,8 +4377,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 4372 | 4377 | }; |
| 4373 | 4378 | }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); |
| 4374 | 4379 | // The stack renders items[0] front-most and tucks the rest behind hover, so |
| 4375 | // ordering is priority: system banners, then the branch-mismatch notice, | |
| 4376 | // and the informational parked-thread banner last — it must never cover another. | |
| 4380 | // ordering is priority: urgent system banners (error/warning variants plus | |
| 4381 | // calm-styled live states flagged `urgent`, like update progress), then | |
| 4382 | // background liveness — its Stop button is the only stop affordance for | |
| 4383 | // settled turns, so a passive "update available" notice must not cover it — | |
| 4384 | // then calm system banners, the woke and branch-mismatch notices, and the | |
| 4385 | // informational parked-thread banner last — it must never cover another. | |
| 4377 | 4386 | const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => { |
| 4378 | 4387 | if (!activeThreadSnoozed && !activeThreadSettled) { |
| 4379 | 4388 | return null; |
| ... | ... | @@ -4423,21 +4432,27 @@ function ChatViewContent(props: ChatViewProps) { |
| 4423 | 4432 | void handleSwitchCheckoutToThread(); |
| 4424 | 4433 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); |
| 4425 | 4434 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 4435 | const isUrgentSystemItem = (item: ComposerBannerStackItem) => | |
| 4436 | item.urgent === true || item.variant === "error" || item.variant === "warning"; | |
| 4437 | const urgentSystemItems = systemComposerBannerItems.filter(isUrgentSystemItem); | |
| 4438 | const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item)); | |
| 4426 | 4439 | const backgroundLivenessItems = |
| 4427 | 4440 | backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; |
| 4428 | 4441 | const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; |
| 4429 | 4442 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; |
| 4430 | 4443 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 4431 | 4444 | return [ |
| 4432 | ...systemComposerBannerItems, | |
| 4445 | ...urgentSystemItems, | |
| 4433 | 4446 | ...backgroundLivenessItems, |
| 4447 | ...calmSystemItems, | |
| 4434 | 4448 | ...wokeThreadItems, |
| 4435 | 4449 | ...parkedThreadItems, |
| 4436 | 4450 | ]; |
| 4437 | 4451 | } |
| 4438 | 4452 | return [ |
| 4439 | ...systemComposerBannerItems, | |
| 4453 | ...urgentSystemItems, | |
| 4440 | 4454 | ...backgroundLivenessItems, |
| 4455 | ...calmSystemItems, | |
| 4441 | 4456 | ...wokeThreadItems, |
| 4442 | 4457 | { |
| 4443 | 4458 | id: `branch-mismatch:${activeBranchMismatchKey}`, |
apps/web/src/components/chat/ComposerBannerStack.test.tsx+18-2| ... | ... | @@ -3,9 +3,12 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; |
| 5 | 5 | |
| 6 | const banner = (id: string): ComposerBannerStackItem => ({ | |
| 6 | const banner = ( | |
| 7 | id: string, | |
| 8 | variant: ComposerBannerStackItem["variant"] = "warning", | |
| 9 | ): ComposerBannerStackItem => ({ | |
| 7 | 10 | id, |
| 8 | variant: "warning", | |
| 11 | variant, | |
| 9 | 12 | icon: <span aria-hidden="true">!</span>, |
| 10 | 13 | title: `${id} warning`, |
| 11 | 14 | }); |
| ... | ... | @@ -29,6 +32,19 @@ describe("ComposerBannerStack", () => { |
| 29 | 32 | expect(markup).toContain("group-focus-within/banner-stack:visible"); |
| 30 | 33 | }); |
| 31 | 34 | |
| 35 | it("colors the collapsed stack cap by the hidden banner's variant, not a fixed warning", () => { | |
| 36 | const neutralBehind = renderToStaticMarkup( | |
| 37 | <ComposerBannerStack items={[banner("front", "default"), banner("stacked", "default")]} />, | |
| 38 | ); | |
| 39 | expect(neutralBehind).toContain("border-border"); | |
| 40 | expect(neutralBehind).not.toContain("border-warning/24"); | |
| 41 | ||
| 42 | const warningBehind = renderToStaticMarkup( | |
| 43 | <ComposerBannerStack items={[banner("front", "default"), banner("stacked", "warning")]} />, | |
| 44 | ); | |
| 45 | expect(warningBehind).toContain("border-warning/24"); | |
| 46 | }); | |
| 47 | ||
| 32 | 48 | it("does not render an expandable region for a single banner", () => { |
| 33 | 49 | const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />); |
| 34 | 50 |
apps/web/src/components/chat/ComposerBannerStack.tsx+18-2| ... | ... | @@ -22,9 +22,23 @@ const exitTransitionStyle = { |
| 22 | 22 | transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`, |
| 23 | 23 | } satisfies CSSProperties; |
| 24 | 24 | |
| 25 | // The collapsed cap peeking above the front banner is the only hint that more | |
| 26 | // banners are stacked behind it, so its border must match the severity of the | |
| 27 | // first hidden banner — a neutral banner must not masquerade as a warning. | |
| 28 | const stackCapBorderClass: Record<ComposerBannerStackItem["variant"], string> = { | |
| 29 | default: "border-border", | |
| 30 | error: "border-destructive/24", | |
| 31 | info: "border-info/24", | |
| 32 | success: "border-success/24", | |
| 33 | warning: "border-warning/24", | |
| 34 | }; | |
| 35 | ||
| 25 | 36 | export interface ComposerBannerStackItem { |
| 26 | 37 | readonly id: string; |
| 27 | 38 | readonly variant: "default" | "error" | "info" | "success" | "warning"; |
| 39 | // Ordering hint for stack assemblers: front this banner even though its | |
| 40 | // variant is calm (e.g. live update progress). The stack itself ignores it. | |
| 41 | readonly urgent?: boolean; | |
| 28 | 42 | readonly icon: ReactNode; |
| 29 | 43 | readonly title: ReactNode; |
| 30 | 44 | readonly description?: ReactNode; |
| ... | ... | @@ -67,6 +81,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 67 | 81 | const stackedItems = items.slice(1); |
| 68 | 82 | const hasStack = stackedItems.length > 0; |
| 69 | 83 | const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; |
| 84 | const firstStackedItem = stackedItems[0]; | |
| 70 | 85 | |
| 71 | 86 | const requestDismiss = (item: ComposerBannerStackItem) => { |
| 72 | 87 | if (!item.onDismiss || exitingItemId) { |
| ... | ... | @@ -90,11 +105,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 90 | 105 | hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null, |
| 91 | 106 | )} |
| 92 | 107 | > |
| 93 | {showCollapsedStackCap ? ( | |
| 108 | {showCollapsedStackCap && firstStackedItem ? ( | |
| 94 | 109 | <div |
| 95 | 110 | className={cn( |
| 96 | 111 | "pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-[22px]", |
| 97 | "border border-b-0 border-warning/24 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]", | |
| 112 | "border border-b-0 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]", | |
| 113 | stackCapBorderClass[firstStackedItem.variant], | |
| 98 | 114 | "transition-opacity duration-150 ease-out", |
| 99 | 115 | "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0", |
| 100 | 116 | )} |