| 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,6 +1934,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 1934 | items.push({ | 1934 | items.push({ |
| 1935 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, | 1935 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, |
| 1936 | variant: "default", | 1936 | variant: "default", |
| 1937 | // Live connection status: calm styling, but it must front the stack. | ||
| 1938 | urgent: true, | ||
| 1937 | icon: ( | 1939 | icon: ( |
| 1938 | <span | 1940 | <span |
| 1939 | className="size-1.5 animate-status-pulse rounded-full bg-foreground" | 1941 | className="size-1.5 animate-status-pulse rounded-full bg-foreground" |
| ... | @@ -1988,6 +1990,9 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1988,6 +1990,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 1988 | items.push({ | 1990 | items.push({ |
| 1989 | id: `server-version:${serverUpdateEnvironmentId}`, | 1991 | id: `server-version:${serverUpdateEnvironmentId}`, |
| 1990 | variant: updateFailed ? "error" : "default", | 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 | // In-flight and failed states carry their own status dot inside | 1996 | // In-flight and failed states carry their own status dot inside |
| 1992 | // ServerUpdateProgress; only the idle offer needs an icon. | 1997 | // ServerUpdateProgress; only the idle offer needs an icon. |
| 1993 | icon: | 1998 | icon: |
| ... | @@ -4372,8 +4377,12 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4372,8 +4377,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 4372 | }; | 4377 | }; |
| 4373 | }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); | 4378 | }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); |
| 4374 | // The stack renders items[0] front-most and tucks the rest behind hover, so | 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, | 4380 | // ordering is priority: urgent system banners (error/warning variants plus |
| 4376 | // and the informational parked-thread banner last — it must never cover another. | 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 | const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => { | 4386 | const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => { |
| 4378 | if (!activeThreadSnoozed && !activeThreadSettled) { | 4387 | if (!activeThreadSnoozed && !activeThreadSettled) { |
| 4379 | return null; | 4388 | return null; |
| ... | @@ -4423,21 +4432,27 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4423,21 +4432,27 @@ function ChatViewContent(props: ChatViewProps) { |
| 4423 | void handleSwitchCheckoutToThread(); | 4432 | void handleSwitchCheckoutToThread(); |
| 4424 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); | 4433 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); |
| 4425 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { | 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 | const backgroundLivenessItems = | 4439 | const backgroundLivenessItems = |
| 4427 | backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; | 4440 | backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; |
| 4428 | const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; | 4441 | const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; |
| 4429 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; | 4442 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; |
| 4430 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { | 4443 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 4431 | return [ | 4444 | return [ |
| 4432 | ...systemComposerBannerItems, | 4445 | ...urgentSystemItems, |
| 4433 | ...backgroundLivenessItems, | 4446 | ...backgroundLivenessItems, |
| 4447 | ...calmSystemItems, | ||
| 4434 | ...wokeThreadItems, | 4448 | ...wokeThreadItems, |
| 4435 | ...parkedThreadItems, | 4449 | ...parkedThreadItems, |
| 4436 | ]; | 4450 | ]; |
| 4437 | } | 4451 | } |
| 4438 | return [ | 4452 | return [ |
| 4439 | ...systemComposerBannerItems, | 4453 | ...urgentSystemItems, |
| 4440 | ...backgroundLivenessItems, | 4454 | ...backgroundLivenessItems, |
| 4455 | ...calmSystemItems, | ||
| 4441 | ...wokeThreadItems, | 4456 | ...wokeThreadItems, |
| 4442 | { | 4457 | { |
| 4443 | id: `branch-mismatch:${activeBranchMismatchKey}`, | 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,9 +3,12 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; | 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 | id, | 10 | id, |
| 8 | variant: "warning", | 11 | variant, |
| 9 | icon: <span aria-hidden="true">!</span>, | 12 | icon: <span aria-hidden="true">!</span>, |
| 10 | title: `${id} warning`, | 13 | title: `${id} warning`, |
| 11 | }); | 14 | }); |
| ... | @@ -29,6 +32,19 @@ describe("ComposerBannerStack", () => { | ... | @@ -29,6 +32,19 @@ describe("ComposerBannerStack", () => { |
| 29 | expect(markup).toContain("group-focus-within/banner-stack:visible"); | 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 | it("does not render an expandable region for a single banner", () => { | 48 | it("does not render an expandable region for a single banner", () => { |
| 33 | const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />); | 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,9 +22,23 @@ const exitTransitionStyle = { |
| 22 | transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`, | 22 | transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`, |
| 23 | } satisfies CSSProperties; | 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 | export interface ComposerBannerStackItem { | 36 | export interface ComposerBannerStackItem { |
| 26 | readonly id: string; | 37 | readonly id: string; |
| 27 | readonly variant: "default" | "error" | "info" | "success" | "warning"; | 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 | readonly icon: ReactNode; | 42 | readonly icon: ReactNode; |
| 29 | readonly title: ReactNode; | 43 | readonly title: ReactNode; |
| 30 | readonly description?: ReactNode; | 44 | readonly description?: ReactNode; |
| ... | @@ -67,6 +81,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro | ... | @@ -67,6 +81,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 67 | const stackedItems = items.slice(1); | 81 | const stackedItems = items.slice(1); |
| 68 | const hasStack = stackedItems.length > 0; | 82 | const hasStack = stackedItems.length > 0; |
| 69 | const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; | 83 | const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; |
| 84 | const firstStackedItem = stackedItems[0]; | ||
| 70 | 85 | ||
| 71 | const requestDismiss = (item: ComposerBannerStackItem) => { | 86 | const requestDismiss = (item: ComposerBannerStackItem) => { |
| 72 | if (!item.onDismiss || exitingItemId) { | 87 | if (!item.onDismiss || exitingItemId) { |
| ... | @@ -90,11 +105,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro | ... | @@ -90,11 +105,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 90 | hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null, | 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 | <div | 109 | <div |
| 95 | className={cn( | 110 | className={cn( |
| 96 | "pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-[22px]", | 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 | "transition-opacity duration-150 ease-out", | 114 | "transition-opacity duration-150 ease-out", |
| 99 | "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0", | 115 | "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0", |
| 100 | )} | 116 | )} |