From 3872f84e63dd4c7d616fd0072d22480a7bfe6d1c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:02:55 +0000 Subject: [PATCH 1/4] feat(chat): show transcript hydration above the composer --- src/browser/components/ChatPane/ChatPane.tsx | 35 ++- .../components/ChatPane/layoutStack.ts | 4 +- .../stories/App.chatLoading.stories.tsx | 231 ++++++++++++++++++ tests/ui/storybook/coverage.test.ts | 8 + 4 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 src/browser/stories/App.chatLoading.stories.tsx diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index f1c333bd245..79965eaedfd 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"; @@ -1702,6 +1702,7 @@ const ChatPaneContent: React.FC = (props) => { revealDecorations={revealDecorations} isStreamStarting={isStreamStarting} isTranscriptCaughtUp={isTranscriptCaughtUp} + isHydratingTranscript={isHydratingTranscript} runtimeConfig={runtimeConfig} isPreStreamAgentTask={isPreStreamAgentTask} preStreamAgentTaskStatus={ @@ -1773,8 +1774,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; @@ -1783,6 +1784,7 @@ interface ChatInputPaneProps { isCompacting: boolean; isStreamStarting: boolean; isTranscriptCaughtUp: boolean; + isHydratingTranscript: boolean; shouldShowPinnedTodoList: boolean; shouldShowReviewsBanner: boolean; concurrentLocalStreamingWorkspaceName: string | null; @@ -1934,13 +1936,26 @@ 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. + if (props.isHydratingTranscript && !props.isStreamStarting && !props.canInterrupt) { + decorationEntries.push( + createChatInputDecorationStackItem({ + key: "transcript-loading", + // Cached rows can be stale until replay completes, even before decorations are ready. + revealBeforeReady: true, + node: ( + +
+
+
+ ), + }) + ); + } + // Keep decorations in the in-flow composer dock so height changes reserve + // transcript clearance in the same layout pass. Synchronous chat state bypasses + // readiness so hydration does not hide loading feedback or the queued follow-up. 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/stories/App.chatLoading.stories.tsx b/src/browser/stories/App.chatLoading.stories.tsx new file mode 100644 index 00000000000..00e12bb1436 --- /dev/null +++ b/src/browser/stories/App.chatLoading.stories.tsx @@ -0,0 +1,231 @@ +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-component="ChatInputDecorationStack"] [role="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"]')!; + 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 history = createAssistantMessage("history", "Previously loaded response.", { + historySequence: 1, + }); + let emitChat: (event: WorkspaceChatMessage) => void; + let subscriptions = 0; + + function setup() { + subscriptions = 0; + selectWorkspace(workspace); + collapseLeftSidebar(); + collapseRightSidebar(); + expandProjects([workspace.projectPath]); + return createMockORPCClient({ + projects: groupWorkspacesByProject([workspace, otherWorkspace]), + workspaces: [workspace, otherWorkspace], + onChat: (workspaceId, emit) => { + if (workspaceId === workspace.id) { + emitChat = emit; + subscriptions += 1; + } else { + emit( + createAssistantMessage("other-history", "Another workspace response.", { + historySequence: 1, + }) + ); + emit({ type: "caught-up", hasOlderHistory: false }); + } + }, + }); + } + const exerciseHydration: AppStory["play"] = async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + 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(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 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 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 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"); From 42747ad0eb49d49938b4d11f73a3a5331910654b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:42:52 +0000 Subject: [PATCH 2/4] fix: keep transcript loading feedback consistent across replays --- src/browser/components/ChatPane/ChatPane.tsx | 35 +++---- src/browser/stores/WorkspaceStore.test.ts | 64 +++++++++++- src/browser/stores/WorkspaceStore.ts | 30 ++---- .../stories/App.chatLoading.stories.tsx | 97 +++++++++++++++++-- 4 files changed, 174 insertions(+), 52 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 79965eaedfd..e7e4e928852 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -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. @@ -1702,7 +1715,6 @@ const ChatPaneContent: React.FC = (props) => { revealDecorations={revealDecorations} isStreamStarting={isStreamStarting} isTranscriptCaughtUp={isTranscriptCaughtUp} - isHydratingTranscript={isHydratingTranscript} runtimeConfig={runtimeConfig} isPreStreamAgentTask={isPreStreamAgentTask} preStreamAgentTaskStatus={ @@ -1784,7 +1796,6 @@ interface ChatInputPaneProps { isCompacting: boolean; isStreamStarting: boolean; isTranscriptCaughtUp: boolean; - isHydratingTranscript: boolean; shouldShowPinnedTodoList: boolean; shouldShowReviewsBanner: boolean; concurrentLocalStreamingWorkspaceName: string | null; @@ -1936,26 +1947,8 @@ const ChatInputPane: React.FC = (props) => { ), }); } - if (props.isHydratingTranscript && !props.isStreamStarting && !props.canInterrupt) { - decorationEntries.push( - createChatInputDecorationStackItem({ - key: "transcript-loading", - // Cached rows can be stale until replay completes, even before decorations are ready. - revealBeforeReady: true, - node: ( - -
-
-
- ), - }) - ); - } // Keep decorations in the in-flow composer dock so height changes reserve - // transcript clearance in the same layout pass. Synchronous chat state bypasses - // readiness so hydration does not hide loading feedback or the queued follow-up. + // transcript clearance in the same layout pass. return ( <> diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index f49bed623c1..626a6118040 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -1272,6 +1272,64 @@ describe("WorkspaceStore", () => { expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(true); }); + it.each(["end", "error", "client reconnect"])( + "re-arms hydration on %s without losing cached rows", + async (termination) => { + 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); + 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(1); + + for (let attempt = 0; attempt < 2; attempt++) { + if (termination === "client reconnect") { + store.setClient(null); + } else { + attempts[attempt].close(); + } + expect( + await waitUntil(() => !store.getWorkspaceState(workspaceId).isHydratingTranscript) + ).toBe(true); + 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); + } + + attempts[2].push(createHistoryMessageEvent("history-1", 1)); + attempts[2].push(sinceCaughtUpEvent()); + expect( + await waitUntil(() => store.getWorkspaceState(workspaceId).isTranscriptCaughtUp) + ).toBe(true); + expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(false); + expect(store.getWorkspaceState(workspaceId).messages).toEqual(cachedMessages); + store.setActiveWorkspaceId(null); + expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(false); + 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 +2066,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 +2081,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); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 41983492018..1b1f01e0e48 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 }; diff --git a/src/browser/stories/App.chatLoading.stories.tsx b/src/browser/stories/App.chatLoading.stories.tsx index 00e12bb1436..4f1edd03e85 100644 --- a/src/browser/stories/App.chatLoading.stories.tsx +++ b/src/browser/stories/App.chatLoading.stories.tsx @@ -1,3 +1,4 @@ +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"; @@ -16,9 +17,7 @@ import { export default { ...appMeta, title: "App/ChatLoading" }; function getLoadingStatus(canvasElement: HTMLElement) { - return canvasElement.querySelector( - '[data-component="ChatInputDecorationStack"] [role="status"]' - ); + return canvasElement.querySelector('[data-testid="transcript-loading-status"]'); } async function switchWorkspace(canvasElement: HTMLElement, workspaceId: string) { @@ -39,7 +38,9 @@ async function checkLoadingLayout(canvasElement: HTMLElement) { const status = getLoadingStatus(canvasElement); await expect(status).toBeVisible(); const dock = status!.closest('[data-component="ChatDockSurface"]')!; - const composer = canvasElement.querySelector('[data-component="ChatInputSurface"]')!; + 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(); @@ -64,25 +65,62 @@ function createHydrationStory(workspaceId: string): AppStory { 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]); - return createMockORPCClient({ - projects: groupWorkspacesByProject([workspace, otherWorkspace]), - workspaces: [workspace, otherWorkspace], + 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.", { @@ -93,6 +131,18 @@ function createHydrationStory(workspaceId: string): AppStory { } }, }); + // 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); @@ -194,6 +244,39 @@ function createHydrationStory(workspaceId: string): AppStory { }); }); + 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 () => { From 6e3559e52309586fdfb53f070548f3375295983e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:17:58 +0000 Subject: [PATCH 3/4] fix: announce transcript hydration once --- src/browser/components/ChatPane/ChatPane.tsx | 2 +- src/browser/stories/App.chatLoading.stories.tsx | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index e7e4e928852..c46d5bded27 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -1693,7 +1693,7 @@ const ChatPaneContent: React.FC = (props) => { {isHydratingTranscript && !shouldMountStreamingBarrier && (
diff --git a/src/browser/stories/App.chatLoading.stories.tsx b/src/browser/stories/App.chatLoading.stories.tsx index 4f1edd03e85..5a11f678be2 100644 --- a/src/browser/stories/App.chatLoading.stories.tsx +++ b/src/browser/stories/App.chatLoading.stories.tsx @@ -146,9 +146,15 @@ function createHydrationStory(workspaceId: string): AppStory { } 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({ @@ -160,6 +166,7 @@ function createHydrationStory(workspaceId: string): AppStory { await expect( await canvas.findByText("Previously loaded response.", {}, { timeout: 5000 }) ).toBeVisible(); + await expect(exposedStatuses()).toHaveLength(0); }); await step( @@ -175,6 +182,8 @@ function createHydrationStory(workspaceId: string): AppStory { 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)); } ); From da65e70cf8d51d6424954b34cfbbf15eef85a596 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:39:46 +0000 Subject: [PATCH 4/4] fix: retain transcript hydration through retry backoff --- src/browser/stores/WorkspaceStore.test.ts | 112 ++++++++++++++++------ src/browser/stores/WorkspaceStore.ts | 5 +- 2 files changed, 87 insertions(+), 30 deletions(-) diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index 626a6118040..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,9 +1272,16 @@ describe("WorkspaceStore", () => { expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(true); }); - it.each(["end", "error", "client reconnect"])( - "re-arms hydration on %s without losing cached rows", - async (termination) => { + 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 @@ -1292,23 +1299,43 @@ describe("WorkspaceStore", () => { } }); createAndAddWorkspace(store, workspaceId); - attempts[0].push(createHistoryMessageEvent("history-1", 1)); - attempts[0].push(fullCaughtUpEvent()); - expect( - await waitUntil(() => store.getWorkspaceState(workspaceId).isTranscriptCaughtUp) - ).toBe(true); + 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(1); + 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 (termination === "client reconnect") { - store.setClient(null); - } else { - attempts[attempt].close(); + 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 + ); } - expect( - await waitUntil(() => !store.getWorkspaceState(workspaceId).isHydratingTranscript) - ).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); @@ -1316,20 +1343,46 @@ describe("WorkspaceStore", () => { expect(replayState.isTranscriptCaughtUp).toBe(false); expect(replayState.messages).toEqual(cachedMessages); } + unsubscribe(); - attempts[2].push(createHistoryMessageEvent("history-1", 1)); - attempts[2].push(sinceCaughtUpEvent()); + 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); - store.setActiveWorkspaceId(null); - expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(false); 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"; @@ -4687,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 1b1f01e0e48..28317bdab7a 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -3928,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))