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) {
19341934 items.push({
19351935 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
19361936 variant: "default",
1937 // Live connection status: calm styling, but it must front the stack.
1938 urgent: true,
19371939 icon: (
19381940 <span
19391941 className="size-1.5 animate-status-pulse rounded-full bg-foreground"
......@@ -1988,6 +1990,9 @@ function ChatViewContent(props: ChatViewProps) {
19881990 items.push({
19891991 id: `server-version:${serverUpdateEnvironmentId}`,
19901992 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,
19911996 // In-flight and failed states carry their own status dot inside
19921997 // ServerUpdateProgress; only the idle offer needs an icon.
19931998 icon:
......@@ -4372,8 +4377,12 @@ function ChatViewContent(props: ChatViewProps) {
43724377 };
43734378 }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);
43744379 // 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.
43774386 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
43784387 if (!activeThreadSnoozed && !activeThreadSettled) {
43794388 return null;
......@@ -4423,21 +4432,27 @@ function ChatViewContent(props: ChatViewProps) {
44234432 void handleSwitchCheckoutToThread();
44244433 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
44254434 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));
44264439 const backgroundLivenessItems =
44274440 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
44284441 const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem];
44294442 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
44304443 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
44314444 return [
4432 ...systemComposerBannerItems,
4445 ...urgentSystemItems,
44334446 ...backgroundLivenessItems,
4447 ...calmSystemItems,
44344448 ...wokeThreadItems,
44354449 ...parkedThreadItems,
44364450 ];
44374451 }
44384452 return [
4439 ...systemComposerBannerItems,
4453 ...urgentSystemItems,
44404454 ...backgroundLivenessItems,
4455 ...calmSystemItems,
44414456 ...wokeThreadItems,
44424457 {
44434458 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";
33
44import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
55
6const banner = (id: string): ComposerBannerStackItem => ({
6const banner = (
7 id: string,
8 variant: ComposerBannerStackItem["variant"] = "warning",
9): ComposerBannerStackItem => ({
710 id,
8 variant: "warning",
11 variant,
912 icon: <span aria-hidden="true">!</span>,
1013 title: `${id} warning`,
1114});
......@@ -29,6 +32,19 @@ describe("ComposerBannerStack", () => {
2932 expect(markup).toContain("group-focus-within/banner-stack:visible");
3033 });
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
3248 it("does not render an expandable region for a single banner", () => {
3349 const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />);
3450
apps/web/src/components/chat/ComposerBannerStack.tsx+18-2
......@@ -22,9 +22,23 @@ const exitTransitionStyle = {
2222 transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`,
2323} 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
2536export interface ComposerBannerStackItem {
2637 readonly id: string;
2738 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;
2842 readonly icon: ReactNode;
2943 readonly title: ReactNode;
3044 readonly description?: ReactNode;
......@@ -67,6 +81,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
6781 const stackedItems = items.slice(1);
6882 const hasStack = stackedItems.length > 0;
6983 const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;
84 const firstStackedItem = stackedItems[0];
7085
7186 const requestDismiss = (item: ComposerBannerStackItem) => {
7287 if (!item.onDismiss || exitingItemId) {
......@@ -90,11 +105,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
90105 hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null,
91106 )}
92107 >
93 {showCollapsedStackCap ? (
108 {showCollapsedStackCap && firstStackedItem ? (
94109 <div
95110 className={cn(
96111 "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],
98114 "transition-opacity duration-150 ease-out",
99115 "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0",
100116 )}