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
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
//
// Cold-start boot feedback (#UX-e2e): a dedicated agent's container takes
// 30–120s+ to warm, and before this fix the composer showed only a static
// placeholder for that whole window — no visible progress, no timeout escape.
// BootStatusIndicator fills that silent pre-send window; these tests lock its
// two states, the escalation timing, and the honest "Open settings" escape
// (the callback opens settings; the label must say so, not imply a retry).

import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import {
BOOT_SLOW_AFTER_MS,
BootStatusIndicator,
} from "./ContinuousChatOverlay";

afterEach(() => {
cleanup();
vi.useRealTimers();
});

describe("BootStatusIndicator", () => {
it("shows an accessible, indeterminate 'Waking …' state before the slow threshold", () => {
vi.useFakeTimers();
render(<BootStatusIndicator agentName="Eliza" onOpenSettings={vi.fn()} />);

const status = screen.getByTestId("chat-boot-status");
expect(status.getAttribute("role")).toBe("status");
expect(status.getAttribute("aria-live")).toBe("polite");
expect(status.getAttribute("data-slow")).toBeNull();
expect(status.textContent).toContain("Waking Eliza…");
// No premature escape affordance while the boot is still nominal.
expect(screen.queryByTestId("chat-boot-open-settings")).toBeNull();
});

it("escalates to a 'taking longer than usual' state with a settings escape after the slow threshold", () => {
vi.useFakeTimers();
render(<BootStatusIndicator agentName="Ada" onOpenSettings={vi.fn()} />);

act(() => {
vi.advanceTimersByTime(BOOT_SLOW_AFTER_MS);
});

const status = screen.getByTestId("chat-boot-status");
expect(status.getAttribute("data-slow")).toBe("true");
expect(status.textContent).toContain("Ada is taking longer than usual");
expect(screen.getByTestId("chat-boot-open-settings").textContent).toBe(
"Open settings",
);
});

it("invokes the settings escape when the escalated action is clicked", () => {
vi.useFakeTimers();
const onOpenSettings = vi.fn();
render(
<BootStatusIndicator agentName="Eliza" onOpenSettings={onOpenSettings} />,
);
act(() => {
vi.advanceTimersByTime(BOOT_SLOW_AFTER_MS);
});

screen.getByTestId("chat-boot-open-settings").click();

expect(onOpenSettings).toHaveBeenCalledTimes(1);
});

it("omits the escape button when no settings handler is supplied", () => {
vi.useFakeTimers();
render(<BootStatusIndicator agentName="Eliza" />);
act(() => {
vi.advanceTimersByTime(BOOT_SLOW_AFTER_MS);
});

expect(
screen.getByTestId("chat-boot-status").getAttribute("data-slow"),
).toBe("true");
expect(screen.queryByTestId("chat-boot-open-settings")).toBeNull();
});

it("drops the spinner animation under reduced motion", () => {
vi.useFakeTimers();
const { container } = render(
<BootStatusIndicator agentName="Eliza" onOpenSettings={vi.fn()} reduce />,
);

expect(container.querySelector(".animate-spin")).toBeNull();
});
});
68 changes: 68 additions & 0 deletions packages/ui/src/components/shell/ContinuousChatOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2970,3 +2970,71 @@ describe("ContinuousChatOverlay — OS assistant / deep-link launch (#9148)", ()
});
});
});

// The cold-boot banner (chat-boot-status) is gated by the parent on a 600ms
// grace delay so a warm agent — where `phase` is momentarily "booting" on first
// paint before the status fetch resolves — never flashes it; only a genuine
// cold boot (still booting past the window) shows it. This exercises that
// parent gate end-to-end, which the isolated BootStatusIndicator test can't.
describe("ContinuousChatOverlay — cold-boot banner grace gate", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});

it("does not flash the banner for a warm agent that leaves booting within the grace window", () => {
const { rerender } = render(
<ContinuousChatOverlay
controller={makeController({ phase: "booting" })}
/>,
);
// Warm agent: booting only briefly, flips ready before 600ms elapses.
act(() => {
vi.advanceTimersByTime(300);
});
rerender(
<ContinuousChatOverlay
controller={makeController({ phase: "summoned" })}
/>,
);
act(() => {
vi.advanceTimersByTime(600);
});
expect(screen.queryByTestId("chat-boot-status")).toBeNull();
});

it("shows the banner once a cold boot outlasts the grace window", () => {
render(
<ContinuousChatOverlay
controller={makeController({ phase: "booting" })}
/>,
);
expect(screen.queryByTestId("chat-boot-status")).toBeNull();
act(() => {
vi.advanceTimersByTime(600);
});
expect(screen.getByTestId("chat-boot-status").textContent).toContain(
"Waking",
);
});

it("hides the banner the moment the agent becomes ready", () => {
const { rerender } = render(
<ContinuousChatOverlay
controller={makeController({ phase: "booting" })}
/>,
);
act(() => {
vi.advanceTimersByTime(600);
});
expect(screen.getByTestId("chat-boot-status")).toBeTruthy();
rerender(
<ContinuousChatOverlay
controller={makeController({ phase: "summoned" })}
/>,
);
expect(screen.queryByTestId("chat-boot-status")).toBeNull();
});
});
116 changes: 116 additions & 0 deletions packages/ui/src/components/shell/ContinuousChatOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,95 @@ function TurnStatusIndicator({
);
}

// After this long still booting, the banner escalates to a "taking longer than
// usual" state with a settings escape, so a stuck boot never reads as a silent
// hang. Exported for the unit test (see the __-seam note below).
export const BOOT_SLOW_AFTER_MS = 90_000;

// Grace before the banner appears: a warm agent leaves the "booting" phase
// within a frame, so only a real cold boot outlasts this and shows the banner
// — no flash on a first paint / warm reconnect.
const BOOT_BANNER_GRACE_MS = 600;

/**
* Cold-start boot feedback (resting, pre-send): an indeterminate spinner + live
* "Waking …" label, escalating after {@link BOOT_SLOW_AFTER_MS} to a "taking
* longer than usual" state with an Open-settings escape. The parent gates
* mounting on {@link BOOT_BANNER_GRACE_MS} (see the render site).
*
* Exported (with BOOT_SLOW_AFTER_MS) only as a unit-test seam — not part of the
* public overlay API; cf. `__renderThreadLineForParity`.
*/
export function BootStatusIndicator({
agentName,
onOpenSettings,
reduce,
}: {
agentName: string;
onOpenSettings?: () => void;
reduce?: boolean;
}): React.JSX.Element {
// Local elapsed timing is the only boot signal the overlay has (agentStatus
// carries no boot-start timestamp), and it suffices: the parent unmounts this
// the instant readiness flips, so the timer never outlives the boot.
const [slow, setSlow] = React.useState(false);
React.useEffect(() => {
const id = window.setTimeout(() => setSlow(true), BOOT_SLOW_AFTER_MS);
return () => window.clearTimeout(id);
}, []);
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
data-testid="chat-boot-status"
data-slow={slow ? "true" : undefined}
className="pointer-events-none relative mb-2 flex w-full justify-center"
>
<span
className={cn(
"inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1.5 text-sm font-medium text-white/85",
FLOAT_SHADOW,
)}
>
{slow ? (
<>
<RotateCcw
className={cn(
"h-3.5 w-3.5 text-accent",
reduce ? "" : "animate-spin [animation-duration:2.4s]",
)}
aria-hidden="true"
/>
<span>{agentName} is taking longer than usual to wake…</span>
{onOpenSettings ? (
<button
type="button"
onClick={onOpenSettings}
data-testid="chat-boot-open-settings"
className="pointer-events-auto ml-1 rounded-full border border-white/20 bg-white/10 px-2 py-0.5 text-[12px] text-white/90 transition-colors hover:border-white/35 hover:bg-white/20"
>
Open settings
</button>
) : null}
</>
) : (
<>
<Loader2
className={cn(
"h-3.5 w-3.5 text-accent",
reduce ? "" : "animate-spin",
)}
aria-hidden="true"
/>
<span>Waking {agentName}…</span>
</>
)}
</span>
</div>
);
}

/**
* One turn of the transcript as a chat bubble — assistant on the left, user on
* the right. Memoized so a live drag (which re-renders the overlay on every
Expand Down Expand Up @@ -2021,6 +2110,23 @@ export function ContinuousChatOverlay({
const hasDraft = draft.trim().length > 0;
const hasImages = pendingImages.length > 0;

// `booting` (= `phase === "booting"`) is true whenever the agent isn't ready
// YET — including first paint before the status fetch resolves, even for a
// warm agent. So require it to hold past BOOT_BANNER_GRACE_MS before showing
// the banner: a warm agent flips ready within a frame and never crosses it.
const [showBootBanner, setShowBootBanner] = React.useState(false);
React.useEffect(() => {
if (!booting) {
setShowBootBanner(false);
return;
}
const id = window.setTimeout(
() => setShowBootBanner(true),
BOOT_BANNER_GRACE_MS,
);
return () => window.clearTimeout(id);
}, [booting]);

// The suggestion strip is a keyboard-style row of one-tap prompts shown in the
// RESTING (closed) state — ready, nothing typed or attached, not recording. It
// unmounts once the sheet opens or a draft starts; this condition also gates
Expand Down Expand Up @@ -4003,6 +4109,16 @@ export function ContinuousChatOverlay({
</div>
) : null}

{/* Cold-start boot feedback — sibling of the model-download banner above.
See BootStatusIndicator; `showBootBanner` is the grace-gated flag. */}
{showBootBanner ? (
<BootStatusIndicator
agentName={agentName}
onOpenSettings={openSettings}
reduce={reduce}
/>
) : null}

{/* Three tailored prompt suggestions — a keyboard-style strip shown in the
resting (closed) state when nothing is typed. Tapping one sends it
immediately, which also pulls the chat sheet up. `order: -1` floats the
Expand Down
Loading