authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 06:10:36-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 10:10:36+00:00
log2e66b1fdfc63d39822cd58948bcf43e8fbc239b6
tree8f7c651fa8e32c970e80870362dc84b85ca82ec5
parent8100062a78f1e1942663a0cea9ddc42d2f835525
signature Signed by PGP key B5690EEEBB952194

fix(web): live background-work banner no longer hides behind the update notice (#5595)

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 <span1940 <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 inside1996 // 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, so4379 // 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";
33
4import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";4import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
55
6const banner = (id: string): ComposerBannerStackItem => ({6const 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 });
3134
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")]} />);
3450
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;
2424
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.
28const 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
25export interface ComposerBannerStackItem {36export 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];
7085
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 <div109 <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 )}