Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<span
className="size-1.5 animate-status-pulse rounded-full bg-foreground"
Expand Down Expand Up @@ -1988,6 +1990,9 @@ function ChatViewContent(props: ChatViewProps) {
items.push({
id: `server-version:${serverUpdateEnvironmentId}`,
variant: updateFailed ? "error" : "default",
// A running update is live progress the user is waiting on; only the
// idle "update available" offer is calm enough to stack behind.
urgent: updateInProgress,
// In-flight and failed states carry their own status dot inside
// ServerUpdateProgress; only the idle offer needs an icon.
icon:
Expand Down Expand Up @@ -4372,8 +4377,12 @@ function ChatViewContent(props: ChatViewProps) {
};
}, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);
// The stack renders items[0] front-most and tucks the rest behind hover, so
// ordering is priority: system banners, then the branch-mismatch notice,
// and the informational parked-thread banner last — it must never cover another.
// ordering is priority: urgent system banners (error/warning variants plus
// calm-styled live states flagged `urgent`, like update progress), then
// background liveness — its Stop button is the only stop affordance for
// settled turns, so a passive "update available" notice must not cover it —
// then calm system banners, the woke and branch-mismatch notices, and the
// informational parked-thread banner last — it must never cover another.
const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
if (!activeThreadSnoozed && !activeThreadSettled) {
return null;
Expand Down Expand Up @@ -4423,21 +4432,27 @@ function ChatViewContent(props: ChatViewProps) {
void handleSwitchCheckoutToThread();
}, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update progress hides behind liveness

Medium Severity

When a server update is running and background liveness is shown, the in-flight update banner stays on the default variant and lands in calmSystemItems, so it stacks behind the liveness banner. Users only see ServerUpdateProgress after hover, while a failed update on the same surface remains in the urgent tier in front.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1461465. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[claude-fable-5] RESPONDING ON BEHALF OF THEO

Real finding, fixed in aa08190. A running update styles as the calm default variant, so the variant-based urgency split stacked its progress behind the liveness banner. Banner items now carry an explicit urgent flag for calm-styled live states (update progress, the reconnect fold), and ordering keys off that plus error/warning variants. Only the idle "update available" offer stacks behind liveness.

Comment thread
cursor[bot] marked this conversation as resolved.
...wokeThreadItems,
...parkedThreadItems,
];
}
return [
...systemComposerBannerItems,
...urgentSystemItems,
...backgroundLivenessItems,
...calmSystemItems,
...wokeThreadItems,
{
id: `branch-mismatch:${activeBranchMismatchKey}`,
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/components/chat/ComposerBannerStack.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <span aria-hidden="true">!</span>,
title: `${id} warning`,
});
Expand All @@ -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(
<ComposerBannerStack items={[banner("front", "default"), banner("stacked", "default")]} />,
);
expect(neutralBehind).toContain("border-border");
expect(neutralBehind).not.toContain("border-warning/24");

const warningBehind = renderToStaticMarkup(
<ComposerBannerStack items={[banner("front", "default"), banner("stacked", "warning")]} />,
);
expect(warningBehind).toContain("border-warning/24");
});

it("does not render an expandable region for a single banner", () => {
const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />);

Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/components/chat/ComposerBannerStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComposerBannerStackItem["variant"], string> = {
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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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 ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-[22px]",
"border border-b-0 border-warning/24 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",
"border border-b-0 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",
stackCapBorderClass[firstStackedItem.variant],
"transition-opacity duration-150 ease-out",
"group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0",
)}
Expand Down
Loading