diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index f1c333bd245..c46d5bded27 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -7,7 +7,7 @@ import React, { useDeferredValue, useMemo, } from "react"; -import { Lightbulb } from "lucide-react"; +import { Lightbulb, Loader2 } from "lucide-react"; import { MessageListProvider } from "@/browser/features/Messages/MessageListContext"; import { cn } from "@/common/lib/utils"; import { ChatInstructionsChatDecoration } from "@/browser/components/InstructionsTab/AdditionalSystemContextScratchpad"; @@ -1689,6 +1689,19 @@ const ChatPaneContent: React.FC = (props) => { )} + {/* Read-only transcripts need replay feedback without an editable composer. */} + {isHydratingTranscript && !shouldMountStreamingBarrier && ( + +
+
+
+ )} {transcriptOnly ? ( // Transcript-only workspaces keep their historical transcript, but the whole // composer surface is replaced with a single read-only notice. @@ -1773,8 +1786,8 @@ interface ChatInputPaneProps { workspaceName: string; /** * False until the chat view's one-commit reveal (transcript + decorations - * together). The decoration lane stays empty before that so a decoration - * can never mount after paint and shift the transcript. + * together). Async decorations stay hidden before that so they cannot + * mount after paint and shift the transcript. */ revealDecorations: boolean; runtimeConfig?: RuntimeConfig; @@ -1934,13 +1947,8 @@ const ChatInputPane: React.FC = (props) => { ), }); } - // The decoration lane lives inside the in-flow sticky composer dock, so a - // decoration mounting/unmounting reflows the transcript clearance in the same - // layout pass; the bottom stays pinned via native anchoring plus the - // scrollport-children ResizeObserver in useAutoScroll. Until the one-commit - // reveal the lane renders empty: readiness is monotonic per mounted - // workspace, so this only ever delays the initial mount — it never unmounts - // visible decorations. + // Keep decorations in the in-flow composer dock so height changes reserve + // transcript clearance in the same layout pass. return ( <> diff --git a/src/browser/components/ChatPane/layoutStack.ts b/src/browser/components/ChatPane/layoutStack.ts index a98cc5659e6..53d3c97ec0d 100644 --- a/src/browser/components/ChatPane/layoutStack.ts +++ b/src/browser/components/ChatPane/layoutStack.ts @@ -16,8 +16,8 @@ export interface LayoutStackItem< export type TranscriptTailStackItem = LayoutStackItem<"transcript-tail">; export type ChatInputDecorationStackItem = LayoutStackItem<"composer-decoration"> & { /** - * Render even before async decoration data is ready. Reserve this for synchronous, - * user-authored state that must stay visible while an active stream bypasses hydration. + * Render even before async decoration data is ready, for synchronous chat state + * that must stay visible during hydration. */ readonly revealBeforeReady?: boolean; }; diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index f49bed623c1..9cfa8b79fb0 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -1176,7 +1176,7 @@ describe("WorkspaceStore", () => { const createdAt = new Date().toISOString(); // Setup mock stream - mockChatScript([{ type: "caught-up" }, tick(10)]); + mockChatScript([{ type: "caught-up" }, tick(10)], { keepOpen: true }); createAndAddWorkspace(store, workspaceId, { name: "test-branch-2", createdAt }); @@ -1272,6 +1272,117 @@ describe("WorkspaceStore", () => { expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(true); }); + it.each([ + ["end", false], + ["end", true], + ["error", false], + ["error", true], + ["client reconnect", false], + ["client reconnect", true], + ] as const)( + "keeps hydration active through %s backoff with cached history %s", + async (termination, cached) => { + const workspaceId = "workspace-retry-hydration"; + const client = getInternal<{ client: Parameters[0] }>( + store + ).client; + const attempts = Array.from({ length: 3 }, () => + createControllableAsyncIterable() + ); + let subscriptions = 0; + mockOnChat.mockImplementation(async function* (_input, options) { + const events = attempts[subscriptions++]; + options?.signal?.addEventListener("abort", () => events.close(), { once: true }); + yield* events.iterable; + if (termination === "error" && !options?.signal?.aborted) { + throw new Error("onChat transport failed"); + } + }); + createAndAddWorkspace(store, workspaceId); + expect(await waitUntil(() => subscriptions === 1)).toBe(true); + if (cached) { + attempts[0].push(createHistoryMessageEvent("history-1", 1)); + attempts[0].push(fullCaughtUpEvent()); + expect( + await waitUntil(() => store.getWorkspaceState(workspaceId).isTranscriptCaughtUp) + ).toBe(true); + } + const cachedMessages = store.getWorkspaceState(workspaceId).messages; + expect(cachedMessages).toHaveLength(cached ? 1 : 0); + const observed: Array<{ isHydratingTranscript: boolean; canInterrupt: boolean }> = []; + const unsubscribe = store.subscribeKey(workspaceId, () => { + const { isHydratingTranscript, canInterrupt } = store.getWorkspaceState(workspaceId); + observed.push({ isHydratingTranscript, canInterrupt }); + }); + + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt > 0) { + attempts[attempt].push({ + type: "stream-start", + workspaceId, + messageId: "buffered-stream", + model: "openai:gpt-4o-mini", + historySequence: 2, + startTime: 1_000, + }); + expect(await waitUntil(() => store.getWorkspaceState(workspaceId).canInterrupt)).toBe( + true + ); + } + const previousUpdates = observed.length; + if (termination === "client reconnect") store.setClient(null); + else attempts[attempt].close(); + expect(await waitUntil(() => observed.length > previousUpdates)).toBe(true); + expect(subscriptions).toBe(attempt + 1); + expect(observed.every((state) => state.isHydratingTranscript)).toBe(true); + expect(observed.at(-1)?.canInterrupt).toBe(false); + if (termination === "client reconnect") store.setClient(client); + expect(await waitUntil(() => subscriptions === attempt + 2)).toBe(true); + const replayState = store.getWorkspaceState(workspaceId); + expect(replayState.isHydratingTranscript).toBe(true); + expect(replayState.isTranscriptCaughtUp).toBe(false); + expect(replayState.messages).toEqual(cachedMessages); + } + unsubscribe(); + + if (cached) attempts[2].push(createHistoryMessageEvent("history-1", 1)); + attempts[2].push(cached ? sinceCaughtUpEvent() : fullCaughtUpEvent()); + expect( + await waitUntil(() => store.getWorkspaceState(workspaceId).isTranscriptCaughtUp) + ).toBe(true); + expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(false); + expect(store.getWorkspaceState(workspaceId).messages).toEqual(cachedMessages); + mockChatScript([], { keepOpen: true }); + } + ); + + it("waits for a client before hydration and clears it on deactivation during backoff", async () => { + const client = getInternal<{ client: Parameters[0] }>( + store + ).client; + store.setClient(null); + const workspaceId = "workspace-pending-client"; + createAndAddWorkspace(store, workspaceId); + expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(false); + const events = createControllableAsyncIterable(); + mockOnChat.mockImplementation(async function* () { + yield* events.iterable; + }); + store.setClient(client); + expect(await waitUntil(() => mockOnChat.mock.calls.length > 0)).toBe(true); + const updates: boolean[] = []; + const unsubscribe = store.subscribeKey(workspaceId, () => { + updates.push(store.getWorkspaceState(workspaceId).isHydratingTranscript); + }); + events.close(); + expect(await waitUntil(() => updates.length > 0)).toBe(true); + expect(updates.every(Boolean)).toBe(true); + store.setActiveWorkspaceId(null); + expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(false); + unsubscribe(); + mockChatScript([], { keepOpen: true }); + }); + it("preserves optimistic startup across full replay resets", () => { const workspaceId = "workspace-full-replay-pending-start"; const requestedModel = "openai:gpt-4o-mini"; @@ -2008,7 +2119,10 @@ describe("WorkspaceStore", () => { const workspaceId = "fine-grained-deltas"; const flushMicrotasks = () => new Promise((resolve) => queueMicrotask(resolve)); const messageId = "stream-message"; + const subscribed = Promise.withResolvers(); + mockChatScript([() => subscribed.resolve()], { keepOpen: true }); createAndAddWorkspace(store, workspaceId); + await subscribed.promise; const rawStore = getInternal<{ states: { bump: (key: string) => void }; streamingStatsStore: { bump: (key: string) => void }; @@ -2020,9 +2134,6 @@ describe("WorkspaceStore", () => { event: WorkspaceChatMessage ) => void; }>(store); - // Dispatch below the caught-up buffering gate: the mock onChat retry loop - // resets transient.caughtUp whenever an await lets it advance, which would - // silently buffer later events. Hydration gating is covered elsewhere. const dispatch = (event: WorkspaceChatMessage) => rawStore.processStreamEvent(workspaceId, store.getAggregator(workspaceId), event); @@ -4629,13 +4740,16 @@ describe("WorkspaceStore", () => { releaseDuplicate = resolve; }); - mockChatScript([ - caughtUpEvent(), - Promise.resolve(), - advisorPhaseEvent(workspaceId, "call-advisor-3", "waiting_for_response", 1), - waitForDuplicate, - advisorPhaseEvent(workspaceId, "call-advisor-3", "waiting_for_response", 2), - ]); + mockChatScript( + [ + caughtUpEvent(), + Promise.resolve(), + advisorPhaseEvent(workspaceId, "call-advisor-3", "waiting_for_response", 1), + waitForDuplicate, + advisorPhaseEvent(workspaceId, "call-advisor-3", "waiting_for_response", 2), + ], + { keepOpen: true } + ); createAndAddWorkspace(store, workspaceId); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 41983492018..28317bdab7a 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -3846,33 +3846,21 @@ export class WorkspaceStore { * Retries on unexpected iterator termination to avoid requiring a full app restart. */ private async runOnChatSubscription(workspaceId: string, signal: AbortSignal): Promise { - // Loop-scoped so the observation survives the generation retry: attaching a - // client aborts the change signal the waiting attempt captured, and the - // retried attempt would otherwise see the client as always-present. - let clientWasMissing = false; await runSubscriptionLoop({ name: "onChat(" + workspaceId + ")", signal, - getClient: async (attemptSignal) => { - if (this.client === null) clientWasMissing = true; - return this.client ?? (await this.waitForClient(attemptSignal)); - }, + getClient: async (attemptSignal) => this.client ?? (await this.waitForClient(attemptSignal)), getClientChangeSignal: () => this.clientChangeController.signal, subscribe: async (client, attemptSignal, abortAttempt) => { - const hadClientAtLoopStart = !clientWasMissing; - clientWasMissing = false; - const initialTransient = this.chatTransientState.get(workspaceId); - if ( - !hadClientAtLoopStart && - initialTransient && - !initialTransient.caughtUp && - !initialTransient.isHydratingTranscript - ) { - initialTransient.isHydratingTranscript = true; - this.states.bump(workspaceId); - } const transient = this.chatTransientState.get(workspaceId); - if (transient) transient.caughtUp = false; + if (transient) { + transient.caughtUp = false; + // Every attempt replays, including retries that keep the same client and cached rows. + if (!transient.isHydratingTranscript) { + transient.isHydratingTranscript = true; + this.states.bump(workspaceId); + } + } const aggregator = this.aggregators.get(workspaceId); let mode: OnChatMode | undefined; const attemptContext: OnChatAttemptContext = { abort: abortAttempt }; @@ -3940,8 +3928,9 @@ export class WorkspaceStore { if (!this.isWorkspaceRegistered(workspaceId)) return; this.clearReplayBuffers(workspaceId); const transient = this.chatTransientState.get(workspaceId); - if (transient?.isHydratingTranscript && !transient.caughtUp) { - transient.isHydratingTranscript = false; + if (transient) { + // Backoff is still catch-up; cleared stream buffers must also invalidate cached barriers. + transient.isHydratingTranscript = true; this.states.bump(workspaceId); } if (transient && !transient.caughtUp && this.preReplayUsageSnapshot.delete(workspaceId)) diff --git a/src/browser/stories/App.chatLoading.stories.tsx b/src/browser/stories/App.chatLoading.stories.tsx new file mode 100644 index 00000000000..5a11f678be2 --- /dev/null +++ b/src/browser/stories/App.chatLoading.stories.tsx @@ -0,0 +1,323 @@ +import { wrapAsyncIterator } from "@orpc/shared"; +import { expect, userEvent, waitFor, within } from "@storybook/test"; +import type { WorkspaceChatMessage } from "@/common/orpc/types"; +import { DEFAULT_MODEL } from "@/common/constants/knownModels"; +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { createMockORPCClient } from "./mocks/orpc"; +import { createAssistantMessage } from "./mocks/messages"; +import { createWorkspace, groupWorkspacesByProject, STABLE_TIMESTAMP } from "./mocks/workspaces"; +import { + collapseLeftSidebar, + collapseRightSidebar, + expandLeftSidebar, + expandProjects, + selectWorkspace, +} from "./helpers/uiState"; + +export default { ...appMeta, title: "App/ChatLoading" }; + +function getLoadingStatus(canvasElement: HTMLElement) { + return canvasElement.querySelector('[data-testid="transcript-loading-status"]'); +} + +async function switchWorkspace(canvasElement: HTMLElement, workspaceId: string) { + expandLeftSidebar(); + const row = await waitFor(async () => { + const element = canvasElement.querySelector( + '[data-workspace-id="' + workspaceId + '"][role="button"]' + ); + await expect(element).not.toBeNull(); + return element!; + }); + await userEvent.click(row); + collapseLeftSidebar(); +} + +async function checkLoadingLayout(canvasElement: HTMLElement) { + await waitFor(async () => { + const status = getLoadingStatus(canvasElement); + await expect(status).toBeVisible(); + const dock = status!.closest('[data-component="ChatDockSurface"]')!; + const composer = canvasElement.querySelector( + '[data-component="ChatInputSurface"], [data-testid="chat-composer-dock"] [role="note"]' + )!; + const statusRect = status!.getBoundingClientRect(); + const dockRect = dock.getBoundingClientRect(); + const composerRect = composer.getBoundingClientRect(); + await expect(statusRect.bottom).toBeLessThanOrEqual(composerRect.top); + await expect(Math.abs(dockRect.left - composerRect.left)).toBeLessThan(1); + await expect(Math.abs(dockRect.right - composerRect.right)).toBeLessThan(1); + await expect(status!.scrollWidth).toBeLessThanOrEqual(status!.clientWidth); + await expect(composerRect.right).toBeLessThanOrEqual( + canvasElement.getBoundingClientRect().right + ); + }); +} + +function createHydrationStory(workspaceId: string): AppStory { + const workspace = createWorkspace({ + id: workspaceId, + name: "loading-history", + projectName: "xum", + }); + const otherWorkspace = createWorkspace({ + id: workspaceId + "-other", + name: "caught-up-history", + projectName: "xum", + }); + const monitorWorkspace = createWorkspace({ + id: workspaceId + "-monitor", + name: "waiting-on-monitor", + projectName: "xum", + }); + const transcriptWorkspace = createWorkspace({ + id: workspaceId + "-transcript", + name: "read-only-history", + projectName: "xum", + transcriptOnly: true, + }); + const workspaces = [workspace, otherWorkspace, monitorWorkspace, transcriptWorkspace]; + const history = createAssistantMessage("history", "Previously loaded response.", { + historySequence: 1, + }); + let emitChat: (event: WorkspaceChatMessage) => void; + let subscriptions = 0; + let transcriptSubscriptions = 0; + let emitTranscript: (event: WorkspaceChatMessage) => void; + + function setup() { + subscriptions = 0; + transcriptSubscriptions = 0; + selectWorkspace(workspace); + collapseLeftSidebar(); + collapseRightSidebar(); + expandProjects([workspace.projectPath]); + const client = createMockORPCClient({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + workspaceActivitySnapshots: { + [monitorWorkspace.id]: { + recency: STABLE_TIMESTAMP, + streaming: false, + lastModel: null, + lastThinkingLevel: null, + activeBashMonitorCount: 1, + }, + }, + onChat: (workspaceId, emit) => { + if (workspaceId === workspace.id) { + emitChat = emit; + subscriptions += 1; + } else if (workspaceId === monitorWorkspace.id) { + emit(history); + } else if (workspaceId === transcriptWorkspace.id) { + emitTranscript = emit; + transcriptSubscriptions += 1; + if (transcriptSubscriptions === 1) { + emit(history); + emit({ + type: "caught-up", + hasOlderHistory: false, + cursor: { history: { messageId: history.id, historySequence: 1 } }, + }); + } + } else { + emit( + createAssistantMessage("other-history", "Another workspace response.", { + historySequence: 1, + }) + ); + emit({ type: "caught-up", hasOlderHistory: false }); + } + }, + }); + // Client swaps between stories must release the previous activity snapshot subscription. + client.workspace.activity.subscribe = (_input, options) => { + async function* iterate() { + yield* []; + await new Promise((resolve) => { + if (options?.signal?.aborted) resolve(); + else options?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + } + return Promise.resolve(wrapAsyncIterator(iterate(), {})); + }; + return client; + } + const exerciseHydration: AppStory["play"] = async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const exposedStatuses = () => + within(canvas.getByTestId("message-window")).queryAllByRole("status"); + await step("First fetch is visible before the transcript and decorations reveal", async () => { + await checkLoadingLayout(canvasElement); + await expect(canvas.getByTestId("transcript-hydration-placeholder")).toBeVisible(); + await expect(exposedStatuses()).toHaveLength(1); + await expect(exposedStatuses()[0]).toBe( + canvas.getByTestId("transcript-hydration-placeholder") + ); + await expect(canvas.getByRole("textbox")).toBeEnabled(); + emitChat(history); + emitChat({ + type: "caught-up", + hasOlderHistory: false, + cursor: { history: { messageId: history.id, historySequence: 1 } }, + }); + await waitFor(() => expect(getLoadingStatus(canvasElement)).toBeNull()); + await expect( + await canvas.findByText("Previously loaded response.", {}, { timeout: 5000 }) + ).toBeVisible(); + await expect(exposedStatuses()).toHaveLength(0); + }); + + await step( + "Switching away clears the status; revisiting keeps cached rows while replaying", + async () => { + await switchWorkspace(canvasElement, otherWorkspace.id); + await expect( + await canvas.findByText("Another workspace response.", {}, { timeout: 5000 }) + ).toBeVisible(); + await expect(getLoadingStatus(canvasElement)).toBeNull(); + await switchWorkspace(canvasElement, workspace.id); + await waitFor(() => expect(subscriptions).toBe(2)); + await checkLoadingLayout(canvasElement); + await expect(canvas.getByText("Previously loaded response.")).toBeVisible(); + await expect(canvas.queryByTestId("transcript-hydration-placeholder")).toBeNull(); + await expect(exposedStatuses()).toHaveLength(1); + await expect(exposedStatuses()[0]).toBe(getLoadingStatus(canvasElement)); + } + ); + + await step("Running init and stream preparation suppress the competing status", async () => { + emitChat({ + type: "init-start", + hookPath: "/project/.xum/init", + timestamp: STABLE_TIMESTAMP, + replay: true, + }); + emitChat({ + type: "init-output", + line: "Preparing workspace", + isError: false, + timestamp: STABLE_TIMESTAMP, + replay: true, + }); + await waitFor(() => expect(getLoadingStatus(canvasElement), "running init").toBeNull()); + emitChat({ type: "init-end", exitCode: 0, timestamp: STABLE_TIMESTAMP, replay: true }); + await checkLoadingLayout(canvasElement); + emitChat({ + type: "stream-lifecycle", + workspaceId: workspace.id, + phase: "preparing", + hadAnyOutput: false, + }); + await waitFor(() => expect(getLoadingStatus(canvasElement), "preparing stream").toBeNull()); + emitChat({ + type: "stream-start", + workspaceId: workspace.id, + messageId: "stream", + model: DEFAULT_MODEL, + historySequence: 2, + startTime: STABLE_TIMESTAMP, + }); + await expect(await canvas.findByText(/streaming\.\.\./)).toBeVisible(); + await expect(getLoadingStatus(canvasElement)).toBeNull(); + emitChat(history); + emitChat({ + type: "caught-up", + replay: "since", + hasOlderHistory: false, + cursor: { history: { messageId: history.id, historySequence: 1 } }, + }); + emitChat({ + type: "stream-delta", + workspaceId: workspace.id, + messageId: "stream", + delta: "Live response.", + tokens: 3, + timestamp: STABLE_TIMESTAMP, + }); + await expect(await canvas.findByText("Live response.")).toBeVisible(); + await expect(getLoadingStatus(canvasElement)).toBeNull(); + emitChat({ + type: "stream-end", + workspaceId: workspace.id, + messageId: "stream", + metadata: { model: DEFAULT_MODEL }, + parts: [{ type: "text", text: "Live response." }], + }); + emitChat({ + type: "stream-lifecycle", + workspaceId: workspace.id, + phase: "idle", + hadAnyOutput: true, + }); + }); + + await step("A monitor barrier suppresses duplicate replay status", async () => { + await switchWorkspace(canvasElement, monitorWorkspace.id); + await expect( + await canvas.findByText(/Waiting on background bash monitor/, {}, { timeout: 5000 }) + ).toBeVisible(); + await expect(getLoadingStatus(canvasElement)).toBeNull(); + }); + + await step("Read-only cached transcripts retain aligned replay feedback", async () => { + await switchWorkspace(canvasElement, transcriptWorkspace.id); + await expect( + await canvas.findByText("Previously loaded response.", {}, { timeout: 5000 }) + ).toBeVisible(); + await expect(getLoadingStatus(canvasElement)).toBeNull(); + await switchWorkspace(canvasElement, otherWorkspace.id); + await expect(await canvas.findByText("Another workspace response.")).toBeVisible(); + await switchWorkspace(canvasElement, transcriptWorkspace.id); + await waitFor(() => expect(transcriptSubscriptions).toBe(2)); + await checkLoadingLayout(canvasElement); + await expect(canvas.getByText("Previously loaded response.")).toBeVisible(); + await expect(canvas.queryByTestId("transcript-hydration-placeholder")).toBeNull(); + await expect(canvas.queryByRole("textbox")).toBeNull(); + emitTranscript(history); + emitTranscript({ + type: "caught-up", + replay: "since", + hasOlderHistory: false, + cursor: { history: { messageId: history.id, historySequence: 1 } }, + }); + await waitFor(() => expect(getLoadingStatus(canvasElement)).toBeNull()); + await expect(canvas.getByText("Previously loaded response.")).toBeVisible(); + }); + + await step( + "A later replay shows the same aligned status without clearing cached messages", + async () => { + await switchWorkspace(canvasElement, otherWorkspace.id); + await expect( + await canvas.findByText("Another workspace response.", {}, { timeout: 5000 }) + ).toBeVisible(); + await switchWorkspace(canvasElement, workspace.id); + await waitFor(() => expect(subscriptions).toBe(3)); + await checkLoadingLayout(canvasElement); + await expect(canvas.getByText("Previously loaded response.")).toBeVisible(); + } + ); + }; + + return { render: () => , play: exerciseHydration }; +} + +export const Replay: AppStory = { + ...createHydrationStory("ws-loading-desktop"), + parameters: { pixel: { matrix: { themes: ["dark", "light"], viewports: ["laptop"] } } }, +}; + +export const Phone: AppStory = { + ...createHydrationStory("ws-loading-phone"), + decorators: [ + (Story) => ( +
+ +
+ ), + ], + globals: { viewport: { value: "mobile1", isRotated: false } }, + parameters: { pixel: { matrix: { viewports: ["phone"] } } }, +}; diff --git a/tests/ui/storybook/coverage.test.ts b/tests/ui/storybook/coverage.test.ts index 73b03ece34e..b320b2cef0f 100644 --- a/tests/ui/storybook/coverage.test.ts +++ b/tests/ui/storybook/coverage.test.ts @@ -85,6 +85,14 @@ describe("Storybook coverage contract", () => { }); describe("Story-specific visual contracts", () => { + test("chat loading phone story pins Pixel and local mobile viewports", () => { + const content = readFileSync("src/browser/stories/App.chatLoading.stories.tsx", "utf-8"); + const phoneStory = content.slice(content.indexOf("export const Phone")); + expect(phoneStory).toMatch(/viewports:\s*\["phone"\]/); + expect(phoneStory).toMatch(/viewport:\s*\{\s*value:\s*"mobile1"/); + expect(phoneStory).toMatch(/width:\s*390/); + }); + test("plan ToC story pins a wide Pixel viewport", () => { const content = readFileSync(PLAN_TOC_STORY_PATH, "utf-8");