diff --git a/scripts/reproWorkspaceSwitchTearWeb.ts b/scripts/reproWorkspaceSwitchTearWeb.ts new file mode 100644 index 0000000000..4db4f7548b --- /dev/null +++ b/scripts/reproWorkspaceSwitchTearWeb.ts @@ -0,0 +1,610 @@ +#!/usr/bin/env bun + +/** + * Browser-mode workspace-switch tear repro. + * + * Why this exists: + * - The remaining artifact was reported in the web/dev-server path, not Electron. + * - The visible tear can show up either as a transcript shift or as the composer briefly + * disappearing while the target workspace opens. + * + * What it does: + * 1. Boots an isolated `make dev-server` instance with a temporary MUX_ROOT. + * 2. Creates two real workspaces in the browser app and sends live mock-chat turns. + * 3. Replays both seen->seen switches and reload->unseen switches while sampling layout. + * 4. Exits with code 1 when the target transcript shifts after it is visible or when the + * composer disappears during a workspace open. + */ +import fs from "fs/promises"; +import net from "net"; +import os from "os"; +import path from "path"; +import { spawn } from "child_process"; +import { chromium, type Page } from "playwright"; +import sharp from "sharp"; + +import { prepareDemoProject } from "../tests/e2e/utils/demoProject"; + +interface WorkspaceSeed { + workspaceId: string; + marker: string; +} + +interface SwitchFrameSample { + frame: number; + timestamp: number; + containsTargetMarker: boolean; + containsSourceMarker: boolean; + messageWindowTop: number | null; + messageWindowHeight: number | null; + scrollTop: number | null; + chatInputHeight: number | null; + imagePath: string; + png: Buffer; +} + +interface OpenTransitionFrameSample { + frame: number; + timestamp: number; + hasInput: boolean; + chatInputHeight: number | null; + hasMessageWindow: boolean; + messageWindowHeight: number | null; + containsTargetMarker: boolean; + loadingWorkspace: boolean; + loadingTranscript: boolean; +} + +function buildMarker(label: string): string { + return `[[workspace-switch-tear:${label}]]`; +} + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to resolve free port"))); + return; + } + const { port } = address; + server.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + server.unref(); + }); +} + +async function waitForHttpReady(url: string, timeoutMs = 60_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const response = await fetch(url, { method: "GET" }); + if (response.ok || response.status === 404) { + return; + } + } catch { + // retry + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${url}`); +} + +function readTrunkBranch(projectPath: string): string { + const result = Bun.spawnSync(["git", "rev-parse", "--abbrev-ref", "HEAD"], { + cwd: projectPath, + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + throw new Error(`Failed to detect trunk branch: ${result.stderr.toString()}`); + } + return result.stdout.toString().trim(); +} + +async function createWorkspaceViaOrpc(args: { + page: Page; + projectPath: string; + branchName: string; + trunkBranch: string; +}): Promise<{ workspaceId: string }> { + return await args.page.evaluate( + async ({ projectPath, branchName, trunkBranch }) => { + const client = window.__ORPC_CLIENT__; + if (!client) throw new Error("ORPC client not initialized"); + await client.projects.setTrust({ projectPath, trusted: true }); + const createResult = await client.workspace.create({ projectPath, branchName, trunkBranch }); + if (!createResult.success) throw new Error(createResult.error); + return { workspaceId: createResult.metadata.id }; + }, + { + projectPath: args.projectPath, + branchName: args.branchName, + trunkBranch: args.trunkBranch, + } + ); +} + +async function waitForProjectPage(page: Page): Promise { + await page.waitForFunction(() => Boolean(window.__ORPC_CLIENT__), { timeout: 60_000 }); + await page.waitForSelector("[data-project-path]", { timeout: 60_000 }); + + // Browser dev-server boots onto the project page with a first-launch provider walkthrough. + // Close it so workspace-row clicks are not intercepted during the repro. + for (const label of ["Close", "Skip"] as const) { + const button = page.getByRole("button", { name: label }).last(); + if (await button.isVisible().catch(() => false)) { + await button.click(); + break; + } + } +} + +async function ensureProjectExpanded(page: Page): Promise { + const projectRow = page.locator("[data-project-path]").first(); + await projectRow.waitFor({ state: "visible", timeout: 60_000 }); + const expandButton = projectRow.locator('[aria-label*="Expand project"]'); + if (await expandButton.isVisible().catch(() => false)) { + await expandButton.click(); + } +} + +async function openWorkspace( + page: Page, + workspaceId: string, + expectedMarker: string +): Promise { + const row = page.locator(`[data-workspace-id="${workspaceId}"][data-workspace-path]`); + await row.waitFor({ state: "visible", timeout: 60_000 }); + await row.scrollIntoViewIfNeeded(); + await row.dispatchEvent("click"); + if (expectedMarker.length > 0) { + await page.waitForFunction( + (marker: string) => document.body.textContent?.includes(marker) ?? false, + expectedMarker, + { timeout: 60_000 } + ); + } +} + +async function sendMessage(page: Page, text: string): Promise { + const input = page.getByRole("textbox", { + name: /Message Claude|Edit your last message/, + }); + await input.waitFor({ state: "visible", timeout: 60_000 }); + await input.fill(text); + await page.keyboard.press("Enter"); +} + +// Wait for the completed assistant row, not just the first visible mock prefix. +// The earlier repro only waited for text to start appearing, which exercised an +// in-flight mock-stream resume gap rather than a completed-chat switch. +async function waitForMockResponse(page: Page, marker: string): Promise { + await page.waitForFunction( + (marker: string) => { + const messages = document.querySelectorAll("[data-message-block]"); + const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null; + const lastMessageText = lastMessage?.textContent ?? ""; + const actionButtonCount = + lastMessage?.querySelectorAll("[data-message-meta-actions] button").length ?? 0; + return lastMessageText.includes(`Mock response: ${marker}`) && actionButtonCount > 1; + }, + marker, + { timeout: 60_000 } + ); +} + +async function captureOpenTransition(args: { + page: Page; + clickWorkspaceId: string; + targetMarker: string; +}): Promise { + const row = args.page.locator( + `[data-workspace-id="${args.clickWorkspaceId}"][data-workspace-path]` + ); + await row.waitFor({ state: "visible", timeout: 60_000 }); + await row.scrollIntoViewIfNeeded(); + await args.page.evaluate((targetMarker: string) => { + ( + window as Window & { + __muxOpenTransitionFramesPromise?: Promise; + } + ).__muxOpenTransitionFramesPromise = new Promise((resolve) => { + const frames: OpenTransitionFrameSample[] = []; + let frame = 0; + const step = () => { + const inputSection = document.querySelector( + '[data-component="ChatInputSection"]' + ) as HTMLElement | null; + const messageWindow = document.querySelector( + '[data-testid="message-window"]' + ) as HTMLElement | null; + const bodyText = document.body.textContent ?? ""; + frames.push({ + frame, + timestamp: performance.now(), + hasInput: inputSection !== null, + chatInputHeight: inputSection?.getBoundingClientRect().height ?? null, + hasMessageWindow: messageWindow !== null, + messageWindowHeight: messageWindow?.getBoundingClientRect().height ?? null, + containsTargetMarker: bodyText.includes(targetMarker), + loadingWorkspace: bodyText.includes("Loading workspace..."), + loadingTranscript: bodyText.includes("Loading transcript..."), + }); + frame += 1; + if (frame < 20) { + requestAnimationFrame(step); + } else { + resolve(frames); + } + }; + requestAnimationFrame(step); + }); + }, args.targetMarker); + await row.dispatchEvent("click"); + return await args.page.evaluate(() => { + return ( + ( + window as Window & { + __muxOpenTransitionFramesPromise?: Promise; + } + ).__muxOpenTransitionFramesPromise ?? Promise.resolve([]) + ); + }); +} + +async function captureSwitch(args: { + page: Page; + sourceMarker: string; + targetMarker: string; + clickWorkspaceId: string; + outputDir: string; + framePrefix: string; +}): Promise { + const row = args.page.locator( + `[data-workspace-id="${args.clickWorkspaceId}"][data-workspace-path]` + ); + await row.waitFor({ state: "visible", timeout: 60_000 }); + await row.scrollIntoViewIfNeeded(); + await row.dispatchEvent("click"); + + const messageWindow = args.page.locator('[data-testid="message-window"]'); + const frames: SwitchFrameSample[] = []; + for (let frame = 0; frame < 12; frame++) { + if (frame > 0) await args.page.waitForTimeout(40); + await messageWindow.waitFor({ state: "visible", timeout: 60_000 }); + const imagePath = path.join( + args.outputDir, + `${args.framePrefix}-${String(frame).padStart(2, "0")}.png` + ); + const [snapshot, png] = await Promise.all([ + args.page.evaluate( + ({ sourceMarker, targetMarker, frame }) => { + const messageWindow = document.querySelector('[data-testid="message-window"]'); + const chatInputSection = document.querySelector( + '[data-component="ChatInputSection"]' + ) as HTMLElement | null; + const rect = messageWindow?.getBoundingClientRect(); + const text = messageWindow?.textContent ?? ""; + return { + frame, + timestamp: performance.now(), + containsTargetMarker: text.includes(targetMarker), + containsSourceMarker: text.includes(sourceMarker), + messageWindowTop: rect?.top ?? null, + messageWindowHeight: rect?.height ?? null, + scrollTop: messageWindow instanceof HTMLDivElement ? messageWindow.scrollTop : null, + chatInputHeight: chatInputSection?.getBoundingClientRect().height ?? null, + }; + }, + { sourceMarker: args.sourceMarker, targetMarker: args.targetMarker, frame } + ), + messageWindow.screenshot({ path: imagePath }), + ]); + frames.push({ ...snapshot, imagePath, png }); + } + return frames; +} + +function detectInputDisappearances(frames: OpenTransitionFrameSample[]) { + const disappearances = [] as Array<{ + frame: number; + loadingWorkspace: boolean; + loadingTranscript: boolean; + }>; + let sawInput = false; + for (const frame of frames) { + if (frame.hasInput) { + sawInput = true; + continue; + } + if (!sawInput) { + continue; + } + disappearances.push({ + frame: frame.frame, + loadingWorkspace: frame.loadingWorkspace, + loadingTranscript: frame.loadingTranscript, + }); + } + return disappearances; +} + +function detectGeometryShift(frames: SwitchFrameSample[]) { + const anchorIndex = frames.findIndex((frame) => frame.containsTargetMarker); + if (anchorIndex === -1) return []; + const anchor = frames[anchorIndex]; + const props: Array< + keyof Pick< + SwitchFrameSample, + "messageWindowTop" | "messageWindowHeight" | "scrollTop" | "chatInputHeight" + > + > = ["messageWindowTop", "messageWindowHeight", "scrollTop", "chatInputHeight"]; + const shifts = [] as Array<{ frame: number; property: string; delta: number }>; + for (const frame of frames.slice(anchorIndex + 1)) { + if (!frame.containsTargetMarker) continue; + for (const prop of props) { + const a = anchor[prop]; + const b = frame[prop]; + if (a == null || b == null) continue; + const delta = b - a; + if (Math.abs(delta) > 1) shifts.push({ frame: frame.frame, property: prop, delta }); + } + } + return shifts; +} + +async function computeDiffRatio(leftPng: Buffer, rightPng: Buffer): Promise { + const [left, right] = await Promise.all([ + sharp(leftPng).ensureAlpha().raw().toBuffer({ resolveWithObject: true }), + sharp(rightPng).ensureAlpha().raw().toBuffer({ resolveWithObject: true }), + ]); + if (left.info.width !== right.info.width || left.info.height !== right.info.height) return 1; + let differentPixels = 0; + const totalPixels = left.info.width * left.info.height; + for (let offset = 0; offset < left.data.length; offset += 4) { + const delta = + Math.abs(left.data[offset] - right.data[offset]) + + Math.abs(left.data[offset + 1] - right.data[offset + 1]) + + Math.abs(left.data[offset + 2] - right.data[offset + 2]); + if (delta > 30) differentPixels += 1; + } + return differentPixels / totalPixels; +} + +async function detectVisualInstability(frames: SwitchFrameSample[]) { + const anchorIndex = frames.findIndex((frame) => frame.containsTargetMarker); + if (anchorIndex === -1) return []; + const diffs = [] as Array<{ fromFrame: number; toFrame: number; ratio: number }>; + for (let index = anchorIndex; index < frames.length - 1; index++) { + const current = frames[index]; + const next = frames[index + 1]; + if (!current.containsTargetMarker || !next.containsTargetMarker) continue; + diffs.push({ + fromFrame: current.frame, + toFrame: next.frame, + ratio: await computeDiffRatio(current.png, next.png), + }); + } + return diffs.filter((diff) => diff.ratio > 0.01); +} + +function stripPng(frames: SwitchFrameSample[]) { + return frames.map(({ png, ...rest }) => rest); +} + +async function main() { + const muxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mux-web-repro-")); + const demoProject = prepareDemoProject(muxRoot); + const backendPort = await getFreePort(); + let vitePort = await getFreePort(); + while (vitePort === backendPort) vitePort = await getFreePort(); + const child = spawn("make", ["dev-server"], { + cwd: process.cwd(), + stdio: ["ignore", "ignore", "ignore"], + env: { + ...process.env, + MUX_ROOT: muxRoot, + MUX_MOCK_AI: "1", + BACKEND_PORT: String(backendPort), + VITE_PORT: String(vitePort), + MUX_ENABLE_TUTORIALS_IN_SANDBOX: "0", + VITE_ALLOWED_HOSTS: "all", + NODE_ENV: "development", + }, + }); + const terminateServer = () => { + if (child.exitCode == null && !child.killed) { + child.kill("SIGTERM"); + } + }; + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, terminateServer); + } + + try { + await waitForHttpReady(`http://127.0.0.1:${vitePort}`); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); + await page.goto(`http://127.0.0.1:${vitePort}`, { waitUntil: "domcontentloaded" }); + await page.evaluate(() => { + localStorage.setItem( + "tutorialState", + JSON.stringify({ + disabled: false, + completed: { settings: true, creation: true, workspace: true }, + }) + ); + }); + await page.reload({ waitUntil: "domcontentloaded" }); + + await waitForProjectPage(page); + const trunkBranch = readTrunkBranch(demoProject.projectPath); + const workspaceA = await createWorkspaceViaOrpc({ + page, + projectPath: demoProject.projectPath, + branchName: `switch-tear-a-${Date.now()}`, + trunkBranch, + }); + const workspaceB = await createWorkspaceViaOrpc({ + page, + projectPath: demoProject.projectPath, + branchName: `switch-tear-b-${Date.now()}`, + trunkBranch, + }); + await ensureProjectExpanded(page); + const markerA = buildMarker("workspace-a-live"); + const markerB = buildMarker("workspace-b-live"); + const promptA = `${markerA} ${"workspace A live chat reproduction. ".repeat(80)}`; + const promptB = `${markerB} ${"workspace B live chat reproduction. ".repeat(80)}`; + const workspaceSeedA = { workspaceId: workspaceA.workspaceId, marker: markerA }; + const workspaceSeedB = { workspaceId: workspaceB.workspaceId, marker: markerB }; + + await openWorkspace(page, workspaceA.workspaceId, ""); + await sendMessage(page, promptA); + await waitForMockResponse(page, markerA); + + await openWorkspace(page, workspaceB.workspaceId, ""); + await sendMessage(page, promptB); + await waitForMockResponse(page, markerB); + + await openWorkspace(page, workspaceA.workspaceId, markerA); + + const outputDir = path.join(muxRoot, "repro-artifacts"); + await fs.mkdir(outputDir, { recursive: true }); + const firstDirectionFrames = await captureSwitch({ + page, + sourceMarker: workspaceSeedA.marker, + targetMarker: workspaceSeedB.marker, + clickWorkspaceId: workspaceB.workspaceId, + outputDir, + framePrefix: "web-first", + }); + await page.waitForFunction( + (marker: string) => document.body.textContent?.includes(marker) ?? false, + workspaceSeedB.marker, + { timeout: 60_000 } + ); + const secondDirectionFrames = await captureSwitch({ + page, + sourceMarker: workspaceSeedB.marker, + targetMarker: workspaceSeedA.marker, + clickWorkspaceId: workspaceA.workspaceId, + outputDir, + framePrefix: "web-second", + }); + await page.waitForFunction( + (marker: string) => document.body.textContent?.includes(marker) ?? false, + workspaceSeedA.marker, + { timeout: 60_000 } + ); + + await page.goto( + `http://127.0.0.1:${vitePort}/project/${encodeURIComponent(demoProject.projectPath)}`, + { + waitUntil: "domcontentloaded", + } + ); + await waitForProjectPage(page); + await ensureProjectExpanded(page); + + const firstOpenAfterReloadFrames = await captureOpenTransition({ + page, + clickWorkspaceId: workspaceA.workspaceId, + targetMarker: workspaceSeedA.marker, + }); + await page.waitForFunction( + (marker: string) => document.body.textContent?.includes(marker) ?? false, + workspaceSeedA.marker, + { timeout: 60_000 } + ); + const firstSwitchToUnseenAfterReloadFrames = await captureOpenTransition({ + page, + clickWorkspaceId: workspaceB.workspaceId, + targetMarker: workspaceSeedB.marker, + }); + await page.waitForFunction( + (marker: string) => document.body.textContent?.includes(marker) ?? false, + workspaceSeedB.marker, + { timeout: 60_000 } + ); + + const result = { + muxRoot, + outputDir, + firstDirection: { + geometryShifts: detectGeometryShift(firstDirectionFrames), + unstableVisualDiffs: await detectVisualInstability(firstDirectionFrames), + frames: stripPng(firstDirectionFrames), + }, + secondDirection: { + geometryShifts: detectGeometryShift(secondDirectionFrames), + unstableVisualDiffs: await detectVisualInstability(secondDirectionFrames), + frames: stripPng(secondDirectionFrames), + }, + firstOpenAfterReload: { + inputDisappearances: detectInputDisappearances(firstOpenAfterReloadFrames), + frames: firstOpenAfterReloadFrames, + }, + firstSwitchToUnseenAfterReload: { + inputDisappearances: detectInputDisappearances(firstSwitchToUnseenAfterReloadFrames), + frames: firstSwitchToUnseenAfterReloadFrames, + }, + }; + const diagnosticsPath = path.join(outputDir, "workspace-switch-tear-web-diagnostics.json"); + await fs.writeFile(diagnosticsPath, JSON.stringify(result, null, 2)); + + const reproduced = + result.firstDirection.geometryShifts.length > 0 || + result.firstDirection.unstableVisualDiffs.length > 0 || + result.secondDirection.geometryShifts.length > 0 || + result.secondDirection.unstableVisualDiffs.length > 0 || + result.firstOpenAfterReload.inputDisappearances.length > 0 || + result.firstSwitchToUnseenAfterReload.inputDisappearances.length > 0; + + console.log( + JSON.stringify( + { + reproduced, + diagnosticsPath, + muxRoot, + outputDir, + firstDirection: { + geometryShifts: result.firstDirection.geometryShifts, + unstableVisualDiffs: result.firstDirection.unstableVisualDiffs, + }, + secondDirection: { + geometryShifts: result.secondDirection.geometryShifts, + unstableVisualDiffs: result.secondDirection.unstableVisualDiffs, + }, + firstOpenAfterReload: { + inputDisappearances: result.firstOpenAfterReload.inputDisappearances, + }, + firstSwitchToUnseenAfterReload: { + inputDisappearances: result.firstSwitchToUnseenAfterReload.inputDisappearances, + }, + }, + null, + 2 + ) + ); + + process.exitCode = reproduced ? 1 : 0; + } finally { + await browser.close(); + } + } finally { + terminateServer(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 2; +}); diff --git a/src/browser/App.tsx b/src/browser/App.tsx index d250543ba5..1fda3782fc 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -1163,15 +1163,28 @@ function AppInner() { setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata)); if (options?.autoNavigate !== false) { - // Only switch to new workspace if user hasn't selected another one - // during the creation process (selectedWorkspace was null when creation started) + let createdSelection: WorkspaceSelection | null = null; setSelectedWorkspace((current) => { if (current !== null) { - // User has already selected another workspace - don't override + // If the user picked another workspace before create/send resolved, + // keep their explicit selection and skip the optimistic starting barrier. return current; } - return toWorkspaceSelection(metadata); + + createdSelection = toWorkspaceSelection(metadata); + return createdSelection; }); + + // WorkspaceContext resolves functional selection updates synchronously + // against its latest ref, so by the time setSelectedWorkspace() returns we + // know whether this creation actually won and can safely mark the + // optimistic starting barrier outside the updater callback. + if (createdSelection) { + workspaceStore.markPendingInitialSend( + metadata.id, + options?.pendingStreamModel ?? null + ); + } } // Track telemetry diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index b36f0b1661..d809c30063 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -300,17 +300,29 @@ export const ChatPane: React.FC = (props) => { // during rapid updates (streaming), keeping the UI responsive. // Must be defined before any early returns to satisfy React Hooks rules. const transformedMessages = useMemo(() => mergeConsecutiveStreamErrors(messages), [messages]); - const deferredTransformedMessages = useDeferredValue(transformedMessages); + const immediateMessageSnapshot = useMemo( + () => ({ workspaceId, messages: transformedMessages }), + [workspaceId, transformedMessages] + ); + const deferredMessageSnapshot = useDeferredValue(immediateMessageSnapshot); // CRITICAL: Show immediate messages when streaming or when message count changes. // useDeferredValue can defer indefinitely if React keeps getting new work (rapid deltas). // During active streaming (reasoning, text), we MUST show immediate updates or the UI // appears frozen while only the token counter updates (reads aggregator directly). + // Also bypass the deferred snapshot when it still belongs to the previous workspace so + // chat switches cannot briefly render stale transcript rows from the old workspace. const shouldBypassDeferral = shouldBypassDeferredMessages( - transformedMessages, - deferredTransformedMessages + immediateMessageSnapshot.messages, + deferredMessageSnapshot.messages, + { + immediateWorkspaceId: workspaceId, + deferredWorkspaceId: deferredMessageSnapshot.workspaceId, + } ); - const deferredMessages = shouldBypassDeferral ? transformedMessages : deferredTransformedMessages; + const deferredMessages = shouldBypassDeferral + ? immediateMessageSnapshot.messages + : deferredMessageSnapshot.messages; const latestMessageId = getLastNonDecorativeMessage(deferredMessages)?.id ?? null; const messageListContextValue = useMemo( @@ -424,11 +436,15 @@ export const ChatPane: React.FC = (props) => { hasInputTarget: !transcriptOnly, }); - // ChatPane is keyed by workspaceId (WorkspaceShell), so per-workspace UI state naturally - // resets on workspace switches. Clear background errors so they don't leak across workspaces. + // Workspace switches should not leak background bash errors into the newly selected chat. useEffect(() => { clearBackgroundBashError(); - }, [clearBackgroundBashError]); + }, [clearBackgroundBashError, workspaceId]); + + useEffect(() => { + setEditingState({ workspaceId, message: undefined }); + setExpandedBashGroups(new Set()); + }, [workspaceId]); const handleChatInputReady = useCallback((api: ChatInputAPI) => { chatInputAPI.current = api; @@ -607,15 +623,19 @@ export const ChatPane: React.FC = (props) => { } }, [workspaceState?.messages, workspaceState?.todos, autoScroll, performAutoScroll]); - // Scroll to bottom when workspace loads or changes - // useLayoutEffect ensures scroll happens synchronously after DOM mutations - // but before browser paint - critical for Chromatic snapshot consistency + const hasLoadedTranscriptRows = !workspaceState.loading && workspaceState.messages.length > 0; + + // Reset transcript scroll ownership when switching workspaces. If the target workspace already + // has cached rows, pin to the bottom before paint; otherwise just re-arm auto-scroll so the + // next hydrated/streaming updates own the tail instead of showing the prior workspace's state. useLayoutEffect(() => { - if (workspaceState && !workspaceState.loading && workspaceState.messages.length > 0) { + if (hasLoadedTranscriptRows) { jumpToBottom(); + return; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId, workspaceState?.loading]); + + setAutoScroll(true); + }, [hasLoadedTranscriptRows, jumpToBottom, setAutoScroll, workspaceId]); // Compute showRetryBarrier once for both keybinds and UI. // Track if last message was interrupted or errored (for RetryBarrier). @@ -630,8 +650,14 @@ export const ChatPane: React.FC = (props) => { const hasInterruptedStream = interruption?.hasInterruptedStream ?? false; // Keep rendering cached transcript rows during incremental catch-up so workspace switches - // feel stable; only show the full placeholder when there's no transcript content yet. - const showTranscriptHydrationPlaceholder = isHydratingTranscript && deferredMessages.length === 0; + // feel stable, but a brand-new chat should keep its starting barrier visible instead of + // flashing transcript placeholders before the first send reaches the workspace history. + const showTranscriptHydrationPlaceholder = + isHydratingTranscript && deferredMessages.length === 0 && !workspaceState.isStreamStarting; + const showEmptyTranscriptPlaceholder = + deferredMessages.length === 0 && + !showTranscriptHydrationPlaceholder && + !workspaceState.isStreamStarting; const showRetryBarrier = !isHydratingTranscript && !workspaceState.canInterrupt && @@ -805,7 +831,7 @@ export const ChatPane: React.FC = (props) => { ref={innerRef} className={cn( "max-w-4xl mx-auto", - (showTranscriptHydrationPlaceholder || deferredMessages.length === 0) && "h-full" + (showTranscriptHydrationPlaceholder || showEmptyTranscriptPlaceholder) && "h-full" )} > {showTranscriptHydrationPlaceholder ? ( @@ -816,7 +842,7 @@ export const ChatPane: React.FC = (props) => {

Loading transcript...

Syncing recent messages for this workspace

- ) : deferredMessages.length === 0 ? ( + ) : showEmptyTranscriptPlaceholder ? (

No Messages Yet

Send a message below to begin

diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx index 6e81a28ff1..db91e84ae3 100644 --- a/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx +++ b/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx @@ -7,6 +7,9 @@ import { installDom } from "../../../../tests/ui/dom"; interface MockWorkspaceState { loading?: boolean; isHydratingTranscript?: boolean; + isStreamStarting?: boolean; + messages?: Array<{ id: string }>; + queuedMessage?: { id: string } | null; } let cleanupDom: (() => void) | null = null; @@ -22,7 +25,24 @@ void mock.module("lottie-react", () => ({ })); void mock.module("@/browser/stores/WorkspaceStore", () => ({ - useWorkspaceState: () => workspaceState, + useWorkspaceState: () => + workspaceState + ? { + messages: [], + queuedMessage: null, + ...workspaceState, + } + : workspaceState, +})); + +void mock.module("../ChatPane/ChatPane", () => ({ + ChatPane: (props: { workspaceId: string }) => ( +
Chat pane for {props.workspaceId}
+ ), +})); + +void mock.module("@/browser/features/RightSidebar/RightSidebar", () => ({ + RightSidebar: () =>
, })); void mock.module("@/browser/contexts/ThemeContext", () => ({ @@ -125,7 +145,7 @@ describe("WorkspaceShell loading placeholders", () => { addReviewMock.mockClear(); }); - it("renders loading animation during hydration in web mode", () => { + it("keeps the chat pane mounted during hydration in web mode", () => { workspaceState = { isHydratingTranscript: true, loading: false, @@ -133,11 +153,65 @@ describe("WorkspaceShell loading placeholders", () => { const view = render(); - expect(view.getByText("Catching up with the agent...")).toBeTruthy(); - expect(view.getByTestId("lottie-animation")).toBeTruthy(); + expect(view.queryByText("Catching up with the agent...")).toBeNull(); + expect(view.getByTestId("chat-pane")).toBeTruthy(); + }); + + it("keeps the chat pane mounted during initial web workspace loading", () => { + workspaceState = { + loading: true, + isHydratingTranscript: true, + isStreamStarting: false, + }; + + const view = render(); + + expect(view.queryByText("Loading workspace...")).toBeNull(); + expect(view.getByTestId("chat-pane")).toBeTruthy(); + }); + + it("keeps cached transcript content visible during web hydration", () => { + workspaceState = { + isHydratingTranscript: true, + isStreamStarting: false, + loading: false, + messages: [{ id: "message-1" }], + queuedMessage: null, + }; + + const view = render(); + + expect(view.queryByText("Catching up with the agent...")).toBeNull(); + expect(view.getByTestId("chat-pane")).toBeTruthy(); + }); + + it("keeps the same chat pane DOM node across workspace switches", () => { + workspaceState = { + isHydratingTranscript: false, + isStreamStarting: false, + loading: false, + messages: [{ id: "message-1" }], + queuedMessage: null, + }; + + const view = render(); + const firstChatPane = view.getByTestId("chat-pane"); + + view.rerender( + + ); + + const secondChatPane = view.getByTestId("chat-pane"); + expect(secondChatPane).toBe(firstChatPane); + expect(secondChatPane.textContent).toContain("workspace-2"); }); - it("renders loading animation during workspace loading", () => { + it("renders loading animation during non-hydrating workspace loading", () => { workspaceState = { loading: true, isHydratingTranscript: false, diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx index e08712942d..3d164e3e27 100644 --- a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx +++ b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx @@ -182,7 +182,7 @@ export const WorkspaceShell: React.FC = (props) => { }); const backgroundBashError = useBackgroundBashError(); - if (!workspaceState || workspaceState.loading) { + if (!workspaceState) { return ( = (props) => { ); } - // Web-only: during workspace switches, the WebSocket subscription needs time to - // catch up. Show a splash instead of flashing stale cached messages. - // Electron's MessageChannel is near-instant so this gate is unnecessary there. - if (workspaceState.isHydratingTranscript && !window.api) { + const shouldKeepChatPaneMountedDuringHydration = + !window.api && workspaceState.isHydratingTranscript && !workspaceState.isStreamStarting; + + // User rationale: a just-created chat should keep showing its startup barrier instead of + // flashing generic loading/catch-up placeholders before the first send reaches onChat. + // Web-only: keep the chat pane mounted during transcript hydration so the composer does not + // disappear while a workspace is opening. ChatPane already owns the transcript-level loading + // placeholder, so swapping the whole shell here causes the vertical tear reproduced by + // scripts/reproWorkspaceSwitchTearWeb.ts. + if ( + workspaceState.loading && + !workspaceState.isStreamStarting && + !shouldKeepChatPaneMountedDuringHydration + ) { return ( @@ -224,9 +234,10 @@ export const WorkspaceShell: React.FC = (props) => { )} style={{ containerType: "inline-size" }} > - {/* Keyed by workspaceId to prevent cross-workspace message-list flashes. */} + {/* Keep the transcript viewport mounted across workspace switches so the browser doesn't + visually tear the pane while the new workspace content hydrates. ChatPane resets its + per-workspace local UI state internally, and the composer remains keyed by workspaceId. */} { updatePersistedStateCalls.length = 0; draftSettingsInvocations = []; draftSettingsState = createDraftSettingsHarness(); + routerState.currentWorkspaceId = null; + routerState.currentProjectId = null; + routerState.pendingDraftId = null; }); afterEach(() => { @@ -915,6 +918,140 @@ describe("useCreationWorkspace", () => { expect(handleSendResult).toEqual({ success: true }); }); + test("marks pending initial send only for auto-navigated creations", async () => { + const listBranchesMock = mock( + (): Promise => + Promise.resolve({ + branches: ["main"], + recommendedTrunk: "main", + }) + ); + const sendMessageMock = mock( + (_args: WorkspaceSendMessageArgs): Promise => + Promise.resolve({ success: true, data: {} } as WorkspaceSendMessageResult) + ); + const createMock = mock( + (_args: WorkspaceCreateArgs): Promise => + Promise.resolve({ + success: true, + metadata: TEST_METADATA, + } as WorkspaceCreateResult) + ); + const nameGenerationMock = mock( + (_args: NameGenerationArgs): Promise => + Promise.resolve({ + success: true, + data: { name: "generated-name", modelUsed: "anthropic:claude-haiku-4-5" }, + } as NameGenerationResult) + ); + setupWindow({ + listBranches: listBranchesMock, + sendMessage: sendMessageMock, + create: createMock, + nameGeneration: nameGenerationMock, + }); + + draftSettingsState = createDraftSettingsHarness({ trunkBranch: "main" }); + routerState.pendingDraftId = "different-draft"; + const onWorkspaceCreated = mock( + ( + metadata: FrontendWorkspaceMetadata, + options?: { autoNavigate?: boolean; pendingStreamModel?: string | null } + ) => ({ + metadata, + options, + }) + ); + + const getHook = renderUseCreationWorkspace({ + projectPath: TEST_PROJECT_PATH, + onWorkspaceCreated, + message: "test message", + draftId: "draft-being-created", + }); + + await waitFor(() => expect(getHook().branches).toEqual(["main"])); + + let handleSendResult: CreationSendResult | undefined; + await act(async () => { + handleSendResult = await getHook().handleSend("test message"); + }); + + expect(handleSendResult).toEqual({ success: true }); + expect(onWorkspaceCreated.mock.calls.length).toBe(1); + expect(onWorkspaceCreated.mock.calls[0][1]).toEqual({ + autoNavigate: false, + pendingStreamModel: null, + }); + }); + + test("handleSend passes the pending stream model only for auto-navigated workspaces", async () => { + const listBranchesMock = mock( + (): Promise => + Promise.resolve({ + branches: ["main"], + recommendedTrunk: "main", + }) + ); + const sendMessageMock = mock( + (_args: WorkspaceSendMessageArgs): Promise => + Promise.resolve({ + success: true as const, + data: {}, + }) + ); + const createMock = mock( + (_args: WorkspaceCreateArgs): Promise => + Promise.resolve({ + success: true, + metadata: TEST_METADATA, + } as WorkspaceCreateResult) + ); + const nameGenerationMock = mock( + (_args: NameGenerationArgs): Promise => + Promise.resolve({ + success: true, + data: { name: "generated-name", modelUsed: "anthropic:claude-haiku-4-5" }, + } as NameGenerationResult) + ); + setupWindow({ + listBranches: listBranchesMock, + sendMessage: sendMessageMock, + create: createMock, + nameGeneration: nameGenerationMock, + }); + + draftSettingsState = createDraftSettingsHarness({ trunkBranch: "main" }); + routerState.pendingDraftId = "draft-being-created"; + const onWorkspaceCreated = mock( + ( + metadata: FrontendWorkspaceMetadata, + options?: { autoNavigate?: boolean; pendingStreamModel?: string | null } + ) => ({ metadata, options }) + ); + + const getHook = renderUseCreationWorkspace({ + projectPath: TEST_PROJECT_PATH, + onWorkspaceCreated, + message: "test message", + draftId: "draft-being-created", + }); + + await waitFor(() => expect(getHook().branches).toEqual(["main"])); + + let handleSendResult: CreationSendResult | undefined; + await act(async () => { + handleSendResult = await getHook().handleSend("test message"); + }); + + expect(handleSendResult).toEqual({ success: true }); + expect(onWorkspaceCreated.mock.calls.length).toBe(1); + expect(onWorkspaceCreated.mock.calls[0][1]).toEqual({ + autoNavigate: true, + pendingStreamModel: "anthropic:claude-opus-4-6", + }); + }); + test("handleSend surfaces backend errors and resets state", async () => { const createMock = mock( (_args: WorkspaceCreateArgs): Promise => @@ -1078,8 +1215,12 @@ function createDraftSettingsHarness( interface HookOptions { projectPath: string; - onWorkspaceCreated: (metadata: FrontendWorkspaceMetadata) => void; + onWorkspaceCreated: ( + metadata: FrontendWorkspaceMetadata, + options?: { autoNavigate?: boolean } + ) => void; message?: string; + draftId?: string | null; } function renderUseCreationWorkspace(options: HookOptions) { diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index b559f9e4b9..c17d9da15c 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -54,6 +54,7 @@ import { normalizeModelInput } from "@/browser/utils/models/normalizeModelInput" import { resolveDevcontainerSelection } from "@/browser/utils/devcontainerSelection"; import { getErrorMessage } from "@/common/utils/errors"; import { normalizeAgentId } from "@/common/utils/agentIds"; +import { workspaceStore } from "@/browser/stores/WorkspaceStore"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; export type CreationSendResult = { success: true } | { success: false; error?: SendMessageError }; @@ -392,6 +393,8 @@ export function useCreationWorkspace({ : null ); + let createdWorkspaceId: string | null = null; + try { // Wait for identity generation to complete (blocks if still in progress) // Returns null if generation failed or manual name is empty (error already set in hook) @@ -506,6 +509,7 @@ export function useCreationWorkspace({ } const { metadata } = createResult; + createdWorkspaceId = metadata.id; // Best-effort: persist the initial AI settings to the backend immediately so this workspace // is portable across devices even before the first stream starts. @@ -559,7 +563,10 @@ export function useCreationWorkspace({ return latestRoute.pendingDraftId === draftId; })(); - onWorkspaceCreated(metadata, { autoNavigate: shouldAutoNavigate }); + onWorkspaceCreated(metadata, { + autoNavigate: shouldAutoNavigate, + pendingStreamModel: shouldAutoNavigate ? baseModel : null, + }); if (typeof draftId === "string" && draftId.trim().length > 0 && promoteWorkspaceDraft) { // UI-only: show the created workspace in-place where the draft was rendered. @@ -594,6 +601,9 @@ export function useCreationWorkspace({ }); if (!sendResult.success) { + if (createdWorkspaceId) { + workspaceStore.clearPendingInitialSendState(createdWorkspaceId); + } if (sendResult.error) { // Persist the failure so the workspace view can surface a toast after navigation. updatePersistedState(getPendingWorkspaceSendErrorKey(metadata.id), sendResult.error); @@ -603,6 +613,9 @@ export function useCreationWorkspace({ return { success: true }; } catch (err) { + if (createdWorkspaceId) { + workspaceStore.clearPendingInitialSendState(createdWorkspaceId); + } const errorMessage = getErrorMessage(err); setToast({ id: Date.now().toString(), diff --git a/src/browser/hooks/useAutoScroll.test.tsx b/src/browser/hooks/useAutoScroll.test.tsx new file mode 100644 index 0000000000..e6c27d53b8 --- /dev/null +++ b/src/browser/hooks/useAutoScroll.test.tsx @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { MutableRefObject, UIEvent } from "react"; +import { GlobalWindow } from "happy-dom"; + +import { useAutoScroll } from "./useAutoScroll"; + +function createScrollEvent(element: HTMLDivElement): UIEvent { + return { currentTarget: element } as unknown as UIEvent; +} + +function attachScrollMetrics(element: HTMLDivElement, initialScrollTop = 900) { + let scrollTop = initialScrollTop; + Object.defineProperty(element, "scrollTop", { + configurable: true, + get: () => scrollTop, + set: (nextValue: number) => { + scrollTop = nextValue; + }, + }); + Object.defineProperty(element, "scrollHeight", { + configurable: true, + get: () => 1300, + }); + Object.defineProperty(element, "clientHeight", { + configurable: true, + get: () => 400, + }); + + return { + setScrollTop(nextValue: number) { + scrollTop = nextValue; + }, + }; +} + +describe("useAutoScroll", () => { + let originalWindow: typeof globalThis.window; + let originalDocument: typeof globalThis.document; + + beforeEach(() => { + originalWindow = globalThis.window; + originalDocument = globalThis.document; + + const domWindow = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.window = domWindow; + globalThis.document = domWindow.document; + }); + + afterEach(() => { + cleanup(); + globalThis.window = originalWindow; + globalThis.document = originalDocument; + }); + + test("ignores upward scrolls without recent user interaction", () => { + const { result } = renderHook(() => useAutoScroll()); + const element = document.createElement("div"); + const scrollMetrics = attachScrollMetrics(element); + + act(() => { + (result.current.contentRef as MutableRefObject).current = element; + result.current.handleScroll(createScrollEvent(element)); + }); + + scrollMetrics.setScrollTop(600); + act(() => { + result.current.handleScroll(createScrollEvent(element)); + }); + + expect(result.current.autoScroll).toBe(true); + }); + + test("disables auto-scroll after a recent user-owned upward scroll", () => { + const { result } = renderHook(() => useAutoScroll()); + const element = document.createElement("div"); + const scrollMetrics = attachScrollMetrics(element); + + act(() => { + (result.current.contentRef as MutableRefObject).current = element; + result.current.handleScroll(createScrollEvent(element)); + }); + + const dateNowSpy = spyOn(Date, "now"); + try { + let now = 1_000_000; + dateNowSpy.mockImplementation(() => now); + scrollMetrics.setScrollTop(600); + + act(() => { + result.current.markUserInteraction(); + now += 1; + result.current.handleScroll(createScrollEvent(element)); + }); + } finally { + dateNowSpy.mockRestore(); + } + + expect(result.current.autoScroll).toBe(false); + }); +}); diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index f62c795da9..9c418aaa3f 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -1058,6 +1058,23 @@ describe("WorkspaceStore", () => { expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(true); }); + it("preserves optimistic startup across full replay resets", () => { + const workspaceId = "workspace-full-replay-pending-start"; + const requestedModel = "openai:gpt-4o-mini"; + const internalStore = store as unknown as { + resetChatStateForReplay: (workspaceId: string) => void; + }; + + createAndAddWorkspace(store, workspaceId); + store.markPendingInitialSend(workspaceId, requestedModel); + + internalStore.resetChatStateForReplay(workspaceId); + + const state = store.getWorkspaceState(workspaceId); + expect(state.isStreamStarting).toBe(true); + expect(state.pendingStreamModel).toBe(requestedModel); + }); + it("clears transcript hydration after repeated catch-up retry failures", async () => { const workspaceId = "workspace-hydration-retry-fallback"; let attempts = 0; @@ -1571,6 +1588,95 @@ describe("WorkspaceStore", () => { expect(store.getWorkspaceState(workspaceId).isStreamStarting).toBe(false); }); + it("clears optimistic starting state on pre-stream abort", async () => { + const workspaceId = "optimistic-pending-start-stream-abort"; + const requestedModel = "openai:gpt-4o-mini"; + let releaseAbort!: () => void; + const abortReady = new Promise((resolve) => { + releaseAbort = resolve; + }); + + mockOnChat.mockImplementation(async function* ( + input?: { workspaceId: string; mode?: unknown }, + options?: { signal?: AbortSignal } + ): AsyncGenerator { + if (input?.workspaceId !== workspaceId) { + await waitForAbortSignal(options?.signal); + return; + } + + yield { type: "caught-up", replay: "full" }; + await abortReady; + yield { + type: "stream-abort", + workspaceId, + messageId: "optimistic-pending-start-stream-abort-msg", + abortReason: "user", + metadata: {}, + }; + await waitForAbortSignal(options?.signal); + }); + + createAndAddWorkspace(store, workspaceId); + store.markPendingInitialSend(workspaceId, requestedModel); + + const sawStarting = await waitUntil( + () => store.getWorkspaceState(workspaceId).isStreamStarting + ); + expect(sawStarting).toBe(true); + + releaseAbort(); + + const clearedStarting = await waitUntil(() => { + const state = store.getWorkspaceState(workspaceId); + return state.isStreamStarting === false; + }); + expect(clearedStarting).toBe(true); + }); + + it("ignores non-streaming activity snapshots while optimistic start awaits replay", async () => { + const workspaceId = "optimistic-pending-start-activity-list"; + const requestedModel = "openai:gpt-4o-mini"; + let releaseCaughtUp!: () => void; + const caughtUpReady = new Promise((resolve) => { + releaseCaughtUp = resolve; + }); + + mockActivityList.mockResolvedValue({ + [workspaceId]: { + recency: 3_000, + streaming: false, + lastModel: requestedModel, + lastThinkingLevel: null, + }, + }); + recreateStore(); + mockOnChat.mockImplementation(async function* ( + input?: { workspaceId: string; mode?: unknown }, + options?: { signal?: AbortSignal } + ): AsyncGenerator { + if (input?.workspaceId !== workspaceId) { + await waitForAbortSignal(options?.signal); + return; + } + + await caughtUpReady; + yield { type: "caught-up", replay: "full" }; + await waitForAbortSignal(options?.signal); + }); + + createAndAddWorkspace(store, workspaceId); + store.markPendingInitialSend(workspaceId, requestedModel); + + const keptStartingBeforeReplay = await waitUntil(() => { + const state = store.getWorkspaceState(workspaceId); + return state.loading === true && state.isStreamStarting === true; + }); + expect(keptStartingBeforeReplay).toBe(true); + + releaseCaughtUp(); + }); + it("replays runtime-status before caught-up when switching back to a preparing workspace", async () => { const workspaceId = "stream-starting-runtime-status-replay"; const otherWorkspaceId = "stream-starting-runtime-status-other"; @@ -1818,6 +1924,61 @@ describe("WorkspaceStore", () => { expect(sawStarting).toBe(true); }); + it("keeps optimistic starting state until buffered first-turn history finishes catching up", async () => { + const workspaceId = "optimistic-pending-start-replay"; + const requestedModel = "openai:gpt-4o-mini"; + let releaseBufferedUser!: () => void; + let releaseCaughtUp!: () => void; + const bufferedUserReady = new Promise((resolve) => { + releaseBufferedUser = resolve; + }); + const caughtUpReady = new Promise((resolve) => { + releaseCaughtUp = resolve; + }); + + mockOnChat.mockImplementation(async function* ( + input?: { workspaceId: string; mode?: unknown }, + options?: { signal?: AbortSignal } + ): AsyncGenerator { + if (input?.workspaceId !== workspaceId) { + await waitForAbortSignal(options?.signal); + return; + } + + await bufferedUserReady; + yield createUserMessageEvent("buffered-first-turn", "hello", 1, 2_750, requestedModel); + await caughtUpReady; + yield { type: "caught-up", replay: "full" }; + await waitForAbortSignal(options?.signal); + }); + + createAndAddWorkspace(store, workspaceId); + store.markPendingInitialSend(workspaceId, requestedModel); + releaseBufferedUser(); + + const keptStartingWhileBuffered = await waitUntil(() => { + const state = store.getWorkspaceState(workspaceId); + return ( + state.loading === true && + state.isStreamStarting === true && + state.pendingStreamModel === requestedModel + ); + }); + expect(keptStartingWhileBuffered).toBe(true); + + releaseCaughtUp(); + + const renderedBufferedHistoryAfterCaughtUp = await waitUntil(() => { + const state = store.getWorkspaceState(workspaceId); + return ( + state.loading === false && + state.isStreamStarting === false && + state.messages.some((message) => message.type === "user") + ); + }); + expect(renderedBufferedHistoryAfterCaughtUp).toBe(true); + }); + it("exposes the pending requested model in sidebar state during startup", async () => { const workspaceId = "stream-starting-pending-model-workspace"; const requestedModel = "openai:gpt-4o-mini"; diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 97b76b0b9b..eef160cdde 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -1579,23 +1579,21 @@ export class WorkspaceStore { : (activity?.lastThinkingLevel ?? aggregator.getCurrentThinkingLevel() ?? null); const hasAuthoritativeStreamLifecycle = streamLifecycle !== null && streamLifecycle.phase !== "idle"; - const hasReplayPreparingLifecycle = - isActiveWorkspace && !transient.caughtUp && streamLifecycle?.phase === "preparing"; + const activePendingStreamStartTime = isActiveWorkspace ? pendingStreamStartTime : null; const aggregatorRecency = aggregator.getRecencyTimestamp(); const recencyTimestamp = aggregatorRecency === null ? (activity?.recency ?? null) : Math.max(aggregatorRecency, activity?.recency ?? aggregatorRecency); - // Treat the backend lifecycle as authoritative, but keep any optimistic - // pre-stream "starting" state scoped to the active, caught-up workspace. - // Reconnect replay is the one exception: if the backend has already re-emitted - // a PREPARING lifecycle snapshot, keep showing startup instead of briefly - // hiding the barrier until caught-up lands. + // User rationale: a brand-new chat should show its startup barrier immediately instead of + // flashing "Catching up"/"No Messages Yet" while the very first send is still in flight. + // The aggregator owns both normal user-message startup and the optimistic new-chat handoff, + // so the workspace only needs to ask whether the active transcript still has a pending start. const isStreamStarting = - (useAggregatorState || hasReplayPreparingLifecycle) && + isActiveWorkspace && + !canInterrupt && (streamLifecycle?.phase === "preparing" || - (!hasAuthoritativeStreamLifecycle && pendingStreamStartTime !== null)) && - !canInterrupt; + (!hasAuthoritativeStreamLifecycle && activePendingStreamStartTime !== null)); // Only actively running init output should bypass transcript hydration. Completed init // rows are still replayed, but they should not suppress the normal catch-up placeholder // for stale cached transcript content on reconnect. @@ -2836,7 +2834,7 @@ export class WorkspaceStore { this.preReplayUsageSnapshot.delete(workspaceId); } - aggregator.clear(); + aggregator.resetForReplay(); // Reset per-workspace transient state so the next replay rebuilds from the backend source of truth. const previousTransient = this.chatTransientState.get(workspaceId); @@ -3209,6 +3207,26 @@ export class WorkspaceStore { } } + markPendingInitialSend(workspaceId: string, pendingStreamModel: string | null): void { + const aggregator = this.aggregators.get(workspaceId); + if (!aggregator) { + return; + } + + aggregator.markOptimisticPendingStreamStart(pendingStreamModel); + this.states.bump(workspaceId); + } + + clearPendingInitialSendState(workspaceId: string): void { + const aggregator = this.aggregators.get(workspaceId); + if (aggregator?.getPendingStreamStartTime() == null) { + return; + } + + aggregator.clearPendingStreamStart(); + this.states.bump(workspaceId); + } + /** * Remove a workspace and clean up subscriptions. */ @@ -3491,10 +3509,11 @@ export class WorkspaceStore { ) { aggregator.clearActiveStreams(); } - // When server confirms no active stream, clear optimistic pending-start state - // so the UI doesn't remain stuck in "starting..." after reconnect. + // When server confirms no active stream, a normal pending-start is stale and should end. + // The only exception is the optimistic new-chat handoff: caught-up can arrive before the + // delayed first send is replayed, so that local barrier must survive until the turn appears. if (serverActiveStreamMessageId === undefined) { - aggregator.clearPendingStreamStart(); + aggregator.clearPendingStreamStartIfNotOptimistic(); } if (replay === "full") { @@ -3815,6 +3834,14 @@ export const workspaceStore = { * before setting it as active. */ addWorkspace: (metadata: FrontendWorkspaceMetadata) => getStoreInstance().addWorkspace(metadata), + /** + * Mark a newly-created workspace as having its first send in flight. + * Used by creation mode so the transcript can show the starting barrier immediately. + */ + markPendingInitialSend: (workspaceId: string, pendingStreamModel: string | null) => + getStoreInstance().markPendingInitialSend(workspaceId, pendingStreamModel), + clearPendingInitialSendState: (workspaceId: string) => + getStoreInstance().clearPendingInitialSendState(workspaceId), /** * Set the active workspace for onChat subscription management. * Exposed for test helpers that bypass React routing effects. diff --git a/src/browser/utils/messages/StreamingMessageAggregator.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.test.ts index f7702f611e..966d6244a2 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.test.ts @@ -2086,6 +2086,41 @@ describe("StreamingMessageAggregator", () => { expect(aggregator.getRuntimeStatus()).toBeNull(); }); + test("keeps an optimistic new-chat start through an empty replay", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); + + aggregator.markOptimisticPendingStreamStart("openai:gpt-4o-mini"); + aggregator.loadHistoricalMessages([], false); + + expect(aggregator.getPendingStreamStartTime()).not.toBeNull(); + expect(aggregator.getPendingStreamModel()).toBe("openai:gpt-4o-mini"); + }); + + test("ends the optimistic new-chat start once replay shows the first user turn", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); + + aggregator.markOptimisticPendingStreamStart("openai:gpt-4o-mini"); + aggregator.loadHistoricalMessages([ + createMuxMessage("user-1", "user", "Hello", { + historySequence: 1, + timestamp: Date.now(), + }), + ]); + + expect(aggregator.getPendingStreamStartTime()).toBeNull(); + expect(aggregator.getPendingStreamModel()).toBeNull(); + }); + + test("preserves an optimistic new-chat start across replay resets", () => { + const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); + + aggregator.markOptimisticPendingStreamStart("openai:gpt-4o-mini"); + aggregator.resetForReplay(); + + expect(aggregator.getPendingStreamStartTime()).not.toBeNull(); + expect(aggregator.getPendingStreamModel()).toBe("openai:gpt-4o-mini"); + }); + test("clears stale pending state when authoritative history now ends with assistant", () => { const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT); seedPendingStreamState(aggregator); diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index b2792c4597..f5b4df0616 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -454,6 +454,11 @@ export class StreamingMessageAggregator { // reflects one-shot/compaction overrides instead of stale localStorage values. private pendingStreamModel: string | null = null; + // A brand-new workspace can auto-navigate before onChat replays the first user turn. + // Keep the startup barrier alive through that empty catch-up window until we see + // either the real user message or a terminal stream event. + private optimisticPendingStreamStart = false; + // Last completed stream timing stats (preserved after stream ends for display) // Unlike activeStreams, this persists until the next stream starts private lastCompletedStreamStats: { @@ -1066,9 +1071,14 @@ export class StreamingMessageAggregator { if (!opts?.skipDerivedState && !hasActiveStream && this.pendingStreamStartTime !== null) { const latestMessage = this.getAllMessages().at(-1); - if (!latestMessage || latestMessage.role === "assistant") { - // Authoritative history now shows an idle/assistant-ended transcript, so any - // preserved "starting..." state came from a disconnected pre-stream turn. + const historySettledThePendingTurn = + latestMessage?.role === "assistant" || + (latestMessage?.role === "user" && this.optimisticPendingStreamStart) || + (latestMessage == null && !this.optimisticPendingStreamStart); + if (historySettledThePendingTurn) { + // User rationale: optimistic startup for a brand-new chat should survive an + // empty caught-up cycle, but once history shows the first turn (or an assistant + // response), the normal transcript can take over and the local barrier should end. this.clearPendingStreamLifecycleState(); } } @@ -1246,6 +1256,19 @@ export class StreamingMessageAggregator { return this.pendingStreamModel; } + markOptimisticPendingStreamStart(model: string | null): void { + this.optimisticPendingStreamStart = true; + this.pendingCompactionRequest = null; + this.pendingStreamModel = model; + this.setPendingStreamStartTime(Date.now()); + } + + clearPendingStreamStartIfNotOptimistic(): void { + if (!this.optimisticPendingStreamStart) { + this.clearPendingStreamStart(); + } + } + private getLatestHistoricalCompactionRequest(): PendingCompactionRequest | null { let sawCompletedCompaction = false; const messages = this.getAllMessages(); @@ -1302,6 +1325,7 @@ export class StreamingMessageAggregator { if (time === null) { this.pendingCompactionRequest = null; this.pendingStreamModel = null; + this.optimisticPendingStreamStart = false; } } @@ -1613,6 +1637,29 @@ export class StreamingMessageAggregator { this.setPendingStreamStartTime(null); } + resetForReplay(): void { + const pendingStreamSnapshot = + this.pendingStreamStartTime === null + ? null + : { + pendingStreamStartTime: this.pendingStreamStartTime, + pendingCompactionRequest: this.pendingCompactionRequest, + pendingStreamModel: this.pendingStreamModel, + optimisticPendingStreamStart: this.optimisticPendingStreamStart, + }; + + this.clear(); + + if (!pendingStreamSnapshot) { + return; + } + + this.pendingStreamStartTime = pendingStreamSnapshot.pendingStreamStartTime; + this.pendingCompactionRequest = pendingStreamSnapshot.pendingCompactionRequest; + this.pendingStreamModel = pendingStreamSnapshot.pendingStreamModel; + this.optimisticPendingStreamStart = pendingStreamSnapshot.optimisticPendingStreamStart; + } + clear(): void { this.messages.clear(); this.activeStreams.clear(); @@ -2470,6 +2517,7 @@ export class StreamingMessageAggregator { } : null; + this.optimisticPendingStreamStart = false; this.pendingStreamModel = muxMetadata?.requestedModel ?? null; if (muxMeta?.displayStatus) { diff --git a/src/browser/utils/messages/messageUtils.test.ts b/src/browser/utils/messages/messageUtils.test.ts index e24f2af484..302e5e3245 100644 --- a/src/browser/utils/messages/messageUtils.test.ts +++ b/src/browser/utils/messages/messageUtils.test.ts @@ -160,6 +160,15 @@ describe("shouldBypassDeferredMessages", () => { ); }); + it("returns true when the deferred snapshot still belongs to the previous workspace", () => { + expect( + shouldBypassDeferredMessages([completedBash], [completedBash], { + immediateWorkspaceId: "workspace-b", + deferredWorkspaceId: "workspace-a", + }) + ).toBe(true); + }); + it("returns true when init output is still running", () => { expect(shouldBypassDeferredMessages([runningInit], [runningInit])).toBe(true); }); diff --git a/src/browser/utils/messages/messageUtils.ts b/src/browser/utils/messages/messageUtils.ts index 05b894a1bf..62118e030c 100644 --- a/src/browser/utils/messages/messageUtils.ts +++ b/src/browser/utils/messages/messageUtils.ts @@ -127,8 +127,20 @@ export function shouldShowInterruptedBarrier( */ export function shouldBypassDeferredMessages( messages: DisplayedMessage[], - deferredMessages: DisplayedMessage[] + deferredMessages: DisplayedMessage[], + options?: { + immediateWorkspaceId?: string; + deferredWorkspaceId?: string; + } ): boolean { + if ( + options?.immediateWorkspaceId !== undefined && + options?.deferredWorkspaceId !== undefined && + options.immediateWorkspaceId !== options.deferredWorkspaceId + ) { + return true; + } + const hasActiveRows = (rows: DisplayedMessage[]) => rows.some( (m) => diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 1cc796a362..1099b95c2d 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1866,7 +1866,7 @@ export class AIService extends EventEmitter { } if (this.mockModeEnabled && this.mockAiStreamPlayer) { - this.mockAiStreamPlayer.stop(workspaceId); + await this.mockAiStreamPlayer.stop(workspaceId); return Ok(undefined); } return this.streamManager.stopStream(workspaceId, options); diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index 5ff4971419..be8777ce78 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -15,6 +15,33 @@ function readWorkspaceId(payload: unknown): string | undefined { return typeof workspaceId === "string" ? workspaceId : undefined; } +function extractText(message: MuxMessage | null | undefined): string { + if (!message) { + return ""; + } + + return message.parts + .filter( + (part): part is Extract => part.type === "text" + ) + .map((part) => part.text) + .join(""); +} + +async function waitForCondition( + check: () => boolean | Promise, + timeoutMs: number +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await check()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for condition after ${timeoutMs}ms`); +} + describe("MockAiStreamPlayer", () => { let historyService: HistoryService; let cleanup: () => Promise; @@ -50,7 +77,7 @@ describe("MockAiStreamPlayer", () => { const firstResult = await player.play([firstTurnUser], workspaceId); expect(firstResult.success).toBe(true); - player.stop(workspaceId); + await player.stop(workspaceId); // Read back what was appended during the first turn const historyResult = await historyService.getLastMessages(workspaceId, 100); @@ -85,7 +112,7 @@ describe("MockAiStreamPlayer", () => { const secondSeq = secondAppend.metadata?.historySequence ?? -1; expect(secondSeq).toBe(firstSeq + 1); - player.stop(workspaceId); + await player.stop(workspaceId); }); test("removes assistant placeholder when aborted before stream scheduling", async () => { @@ -152,6 +179,218 @@ describe("MockAiStreamPlayer", () => { expect(storedMessages.some((msg) => msg.id === assistantMsg.id)).toBe(false); }); + test("writes partial assistant state while a mock stream is still in progress", async () => { + const aiServiceStub = new EventEmitter(); + + const player = new MockAiStreamPlayer({ + historyService, + aiService: aiServiceStub as unknown as AIService, + }); + + const workspaceId = "workspace-partial-progress"; + const firstDelta = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for stream-delta")); + }, 1000); + + aiServiceStub.on("stream-delta", (payload: unknown) => { + if (readWorkspaceId(payload) !== workspaceId) { + return; + } + clearTimeout(timeout); + resolve(); + }); + }); + + const userMessage = createMuxMessage("user-partial", "user", "[force] keep streaming", { + timestamp: Date.now(), + }); + + const playResult = await player.play([userMessage], workspaceId); + expect(playResult.success).toBe(true); + + await firstDelta; + await waitForCondition( + async () => (await historyService.readPartial(workspaceId)) !== null, + 1000 + ); + + const partial = await historyService.readPartial(workspaceId); + expect(partial).not.toBeNull(); + expect(partial?.metadata?.partial).toBe(true); + expect(partial?.id).toMatch(/^msg-mock-/); + expect(extractText(partial).length).toBeGreaterThan(0); + + await player.stop(workspaceId); + await waitForCondition( + async () => (await historyService.readPartial(workspaceId)) === null, + 1000 + ); + }); + + test("waits for partial cleanup before a replacement stream starts writing its own partial", async () => { + const aiServiceStub = new EventEmitter(); + + const player = new MockAiStreamPlayer({ + historyService, + aiService: aiServiceStub as unknown as AIService, + }); + + const originalDeletePartial = historyService.deletePartial.bind(historyService); + spyOn(historyService, "deletePartial").mockImplementation(async (workspaceIdToDelete) => { + await new Promise((resolve) => setTimeout(resolve, 200)); + return await originalDeletePartial(workspaceIdToDelete); + }); + + const workspaceId = "workspace-partial-replacement"; + const firstUserMessage = createMuxMessage( + "user-partial-first", + "user", + "[force] first-partial-marker keep streaming", + { + timestamp: Date.now(), + } + ); + + const firstPlayResult = await player.play([firstUserMessage], workspaceId); + expect(firstPlayResult.success).toBe(true); + + await waitForCondition( + async () => (await historyService.readPartial(workspaceId)) !== null, + 1500 + ); + + const firstPartial = await historyService.readPartial(workspaceId); + expect(firstPartial).not.toBeNull(); + + const secondUserMessage = createMuxMessage( + "user-partial-second", + "user", + "[force] second-partial-marker keep streaming", + { + timestamp: Date.now(), + } + ); + + const secondPlayResult = await player.play([secondUserMessage], workspaceId); + expect(secondPlayResult.success).toBe(true); + + await waitForCondition(async () => { + const partial = await historyService.readPartial(workspaceId); + return partial !== null && partial.id !== firstPartial?.id; + }, 2000); + + await new Promise((resolve) => setTimeout(resolve, 250)); + + const replacementPartial = await historyService.readPartial(workspaceId); + expect(replacementPartial).not.toBeNull(); + expect(replacementPartial?.id).not.toBe(firstPartial?.id); + + await player.stop(workspaceId); + await waitForCondition( + async () => (await historyService.readPartial(workspaceId)) === null, + 1500 + ); + }); + + test("suppresses stale stream errors after a replacement stream cancels the old one", async () => { + const aiServiceStub = new EventEmitter(); + + const player = new MockAiStreamPlayer({ + historyService, + aiService: aiServiceStub as unknown as AIService, + }); + + const originalDeletePartial = historyService.deletePartial.bind(historyService); + let deletePartialCallCount = 0; + spyOn(historyService, "deletePartial").mockImplementation(async (workspaceIdToDelete) => { + deletePartialCallCount += 1; + if (deletePartialCallCount === 1) { + await new Promise((resolve) => setTimeout(resolve, 300)); + } + return await originalDeletePartial(workspaceIdToDelete); + }); + + const workspaceId = "workspace-stale-stream-error"; + const errorEvents: Array<{ messageId?: string }> = []; + aiServiceStub.on("error", (payload: unknown) => { + if (readWorkspaceId(payload) !== workspaceId) { + return; + } + errorEvents.push(payload as { messageId?: string }); + }); + + const firstUserMessage = createMuxMessage( + "user-stream-error-first", + "user", + "[mock:error:api] Trigger API error", + { + timestamp: Date.now(), + } + ); + + const firstPlayResult = await player.play([firstUserMessage], workspaceId); + expect(firstPlayResult.success).toBe(true); + + await waitForCondition(() => deletePartialCallCount >= 1, 1000); + + const replacementUserMessage = createMuxMessage( + "user-stream-error-second", + "user", + "[force] replacement stream after cancelled error", + { + timestamp: Date.now(), + } + ); + + const replacementPlayResult = await player.play([replacementUserMessage], workspaceId); + expect(replacementPlayResult.success).toBe(true); + + await waitForCondition( + async () => (await historyService.readPartial(workspaceId)) !== null, + 1500 + ); + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(errorEvents).toHaveLength(0); + + await player.stop(workspaceId); + await waitForCondition(() => !player.isStreaming(workspaceId), 1000); + }); + + test("commits the full assistant message and clears partial state on stream end", async () => { + const aiServiceStub = new EventEmitter(); + + const player = new MockAiStreamPlayer({ + historyService, + aiService: aiServiceStub as unknown as AIService, + }); + + const workspaceId = "workspace-partial-commit"; + const userMessage = createMuxMessage( + "user-commit", + "user", + "[mock:list-languages] List 3 programming languages", + { + timestamp: Date.now(), + } + ); + + const playResult = await player.play([userMessage], workspaceId); + expect(playResult.success).toBe(true); + + await waitForCondition(() => !player.isStreaming(workspaceId), 2000); + + const partial = await historyService.readPartial(workspaceId); + expect(partial).toBeNull(); + + const historyResult = await historyService.getLastMessages(workspaceId, 10); + const historyMessages = historyResult.success ? historyResult.data : []; + const assistantMessage = historyMessages.find((message) => message.role === "assistant"); + expect(assistantMessage).toBeDefined(); + expect(extractText(assistantMessage)).toContain("Here are three programming languages"); + }); + test("stop prevents queued stream events from emitting", async () => { const aiServiceStub = new EventEmitter(); @@ -185,7 +424,7 @@ describe("MockAiStreamPlayer", () => { if (!stopped) { stopped = true; clearTimeout(timeout); - player.stop(workspaceId); + void player.stop(workspaceId); resolve(); } }); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 66e52a7a94..7a9be415f5 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -16,6 +16,7 @@ import type { import { MockAiRouter } from "./mockAiRouter"; import { buildMockStreamEventsFromReply } from "./mockAiStreamAdapter"; import type { + CompletedMessagePart, StreamStartEvent, StreamDeltaEvent, StreamEndEvent, @@ -107,6 +108,8 @@ async function tokenizeWithMockModel(text: string, context: string): Promise>; messageId: string; + historySequence: number; + startTime: number; + model: string; + parts: MuxMessage["parts"]; + partialWriteTimer: ReturnType | null; eventQueue: Array<() => Promise>; isProcessing: boolean; cancelled: boolean; @@ -217,13 +225,13 @@ export class MockAiStreamPlayer { } }); } - stop(workspaceId: string): void { + private async stopActiveStream(workspaceId: string): Promise { const active = this.activeStreams.get(workspaceId); if (!active) return; active.cancelled = true; - // Emit stream-abort event to mirror real streaming behavior + // Emit stream-abort event to mirror real streaming behavior before we await disk cleanup. this.deps.aiService.emit("stream-abort", { type: "stream-abort", workspaceId, @@ -232,6 +240,18 @@ export class MockAiStreamPlayer { }); this.cleanup(workspaceId); + + // User-initiated mock interrupts should not leave behind resumable partial state. + const deletePartialResult = await this.deps.historyService.deletePartial(workspaceId); + if (!deletePartialResult.success) { + log.error( + `Failed to clear mock partial on stop for ${active.messageId}: ${deletePartialResult.error}` + ); + } + } + + async stop(workspaceId: string): Promise { + await this.stopActiveStream(workspaceId); } async play( @@ -352,9 +372,10 @@ export class MockAiStreamPlayer { historySequence = assistantMessage.metadata?.historySequence ?? historySequence; - // Cancel any existing stream before starting a new one + // Cancel any existing stream before starting a new one. Await partial cleanup so the old + // stream cannot delete the replacement stream's partial snapshot after it begins writing. if (this.isStreaming(workspaceId)) { - this.stop(workspaceId); + await this.stopActiveStream(workspaceId); } this.scheduleEvents(workspaceId, events, messageId, historySequence); @@ -381,6 +402,11 @@ export class MockAiStreamPlayer { this.activeStreams.set(workspaceId, { timers, messageId, + historySequence, + startTime: Date.now(), + model: KNOWN_MODELS.OPUS.id, + parts: [], + partialWriteTimer: null, eventQueue: [], isProcessing: false, cancelled: false, @@ -424,6 +450,160 @@ export class MockAiStreamPlayer { active.isProcessing = false; } + private appendTextPart(active: ActiveStream, text: string, timestamp: number): void { + const lastPart = active.parts[active.parts.length - 1]; + if (lastPart?.type === "text") { + lastPart.text += text; + return; + } + + active.parts.push({ + type: "text", + text, + timestamp, + }); + } + + private appendReasoningPart(active: ActiveStream, text: string, timestamp: number): void { + const lastPart = active.parts[active.parts.length - 1]; + if (lastPart?.type === "reasoning") { + lastPart.text += text; + return; + } + + active.parts.push({ + type: "reasoning", + text, + timestamp, + }); + } + + private setToolPartInput( + active: ActiveStream, + event: Extract, + timestamp: number + ): void { + const existingIndex = active.parts.findIndex( + (part) => part.type === "dynamic-tool" && part.toolCallId === event.toolCallId + ); + const nextPart: MuxMessage["parts"][number] = { + type: "dynamic-tool", + state: "input-available", + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.args, + timestamp, + }; + + if (existingIndex >= 0) { + active.parts[existingIndex] = nextPart; + return; + } + + active.parts.push(nextPart); + } + + private setToolPartOutput( + active: ActiveStream, + event: Extract, + timestamp: number + ): void { + const existingIndex = active.parts.findIndex( + (part) => part.type === "dynamic-tool" && part.toolCallId === event.toolCallId + ); + const previousPart = existingIndex >= 0 ? active.parts[existingIndex] : undefined; + const nextPart: MuxMessage["parts"][number] = { + type: "dynamic-tool", + state: "output-available", + toolCallId: event.toolCallId, + toolName: event.toolName, + input: + previousPart?.type === "dynamic-tool" + ? previousPart.input + : { prompt: "mock tool input unavailable" }, + output: event.result, + timestamp, + }; + + if (existingIndex >= 0) { + active.parts[existingIndex] = nextPart; + return; + } + + active.parts.push(nextPart); + } + + private schedulePartialWrite(workspaceId: string, active: ActiveStream): void { + if (active.cancelled || active.partialWriteTimer !== null) { + return; + } + + active.partialWriteTimer = setTimeout(() => { + active.partialWriteTimer = null; + this.enqueueEvent(workspaceId, active.messageId, async () => { + const current = this.activeStreams.get(workspaceId); + if (!current || current !== active || current.cancelled) { + return; + } + await this.writePartialFromActiveStream(workspaceId, current); + }); + }, MOCK_PARTIAL_WRITE_THROTTLE_MS); + } + + // Mock mode used to keep only the empty assistant placeholder in chat.jsonl until stream-end. + // When a browser workspace switch backgrounded that turn mid-stream, reopening the workspace + // had no authoritative partial transcript to merge back in. Persisting the in-flight assistant + // parts here keeps mock-mode reconnects aligned with the real stream manager. + private async writePartialFromActiveStream( + workspaceId: string, + active: ActiveStream + ): Promise { + if (active.parts.length === 0 || active.cancelled) { + return; + } + + const partialMessage: MuxMessage = { + id: active.messageId, + role: "assistant", + metadata: { + historySequence: active.historySequence, + timestamp: active.startTime, + model: active.model, + partial: true, + }, + parts: structuredClone(active.parts), + }; + + const writeResult = await this.deps.historyService.writePartial(workspaceId, partialMessage); + if (!writeResult.success) { + log.error(`Failed to write mock partial for ${active.messageId}: ${writeResult.error}`); + } + } + + private buildCompletedParts( + active: ActiveStream, + completedParts: StreamEndEvent["parts"] + ): CompletedMessagePart[] { + if (active.parts.length === 0) { + return completedParts; + } + + const nextParts = structuredClone(active.parts) as CompletedMessagePart[]; + const completedTextPart = completedParts.find((part) => part.type === "text"); + if (!completedTextPart) { + return nextParts; + } + + const lastTextIndex = nextParts.findLastIndex((part) => part.type === "text"); + if (lastTextIndex >= 0) { + nextParts[lastTextIndex] = completedTextPart; + return nextParts; + } + + nextParts.push(completedTextPart); + return nextParts; + } + private async dispatchEvent( workspaceId: string, event: MockAssistantEvent, @@ -447,6 +627,8 @@ export class MockAiStreamPlayer { ...(event.mode && { mode: event.mode }), ...(event.thinkingLevel && { thinkingLevel: event.thinkingLevel }), }; + active.model = event.model; + active.startTime = payload.startTime; this.deps.aiService.emit("stream-start", payload); break; } @@ -462,6 +644,8 @@ export class MockAiStreamPlayer { tokens, timestamp: Date.now(), }; + this.appendReasoningPart(active, event.text, payload.timestamp); + this.schedulePartialWrite(workspaceId, active); this.deps.aiService.emit("reasoning-delta", payload); break; } @@ -480,6 +664,8 @@ export class MockAiStreamPlayer { tokens, timestamp: Date.now(), }; + this.setToolPartInput(active, event, payload.timestamp); + this.schedulePartialWrite(workspaceId, active); this.deps.aiService.emit("tool-call-start", payload); break; } @@ -506,6 +692,8 @@ export class MockAiStreamPlayer { result: event.result, timestamp: Date.now(), }; + this.setToolPartOutput(active, event, payload.timestamp); + this.schedulePartialWrite(workspaceId, active); this.deps.aiService.emit("tool-call-end", payload); break; } @@ -526,11 +714,24 @@ export class MockAiStreamPlayer { tokens, timestamp: Date.now(), }; + this.appendTextPart(active, event.text, payload.timestamp); + this.schedulePartialWrite(workspaceId, active); this.deps.aiService.emit("stream-delta", payload); break; } case "stream-error": { const payload: MockStreamErrorEvent = event; + const deletePartialResult = await this.deps.historyService.deletePartial(workspaceId); + if (!deletePartialResult.success) { + log.error(`Failed to clear mock partial for ${messageId}: ${deletePartialResult.error}`); + } + + // Replacement streams can cancel this handler while deletePartial() is in flight. + // Ignore the stale error once the original active stream has been cancelled or replaced. + if (active.cancelled || this.activeStreams.get(workspaceId) !== active) { + return; + } + this.deps.aiService.emit( "error", createErrorEvent(workspaceId, { @@ -543,6 +744,11 @@ export class MockAiStreamPlayer { break; } case "stream-end": { + if (active.partialWriteTimer) { + clearTimeout(active.partialWriteTimer); + active.partialWriteTimer = null; + } + const completedParts = this.buildCompletedParts(active, event.parts); const payload: StreamEndEvent = { type: "stream-end", workspaceId, @@ -551,7 +757,7 @@ export class MockAiStreamPlayer { model: event.metadata.model, systemMessageTokens: event.metadata.systemMessageTokens, }, - parts: event.parts, + parts: completedParts, }; // Update history with completed message (mirrors real StreamManager behavior). @@ -565,7 +771,7 @@ export class MockAiStreamPlayer { const completedMessage: MuxMessage = { id: messageId, role: "assistant", - parts: event.parts, + parts: completedParts, metadata: { ...existingMessage.metadata, model: event.metadata.model, @@ -582,6 +788,10 @@ export class MockAiStreamPlayer { } } } + const deletePartialResult = await this.deps.historyService.deletePartial(workspaceId); + if (!deletePartialResult.success) { + log.error(`Failed to clear mock partial for ${messageId}: ${deletePartialResult.error}`); + } if (active.cancelled) return; @@ -598,6 +808,11 @@ export class MockAiStreamPlayer { active.cancelled = true; + if (active.partialWriteTimer) { + clearTimeout(active.partialWriteTimer); + active.partialWriteTimer = null; + } + // Clear all pending timers for (const timer of active.timers) { clearTimeout(timer); diff --git a/tests/ui/chat/bottomLayoutShift.test.ts b/tests/ui/chat/bottomLayoutShift.test.ts index bda9700257..11fd5d9f6a 100644 --- a/tests/ui/chat/bottomLayoutShift.test.ts +++ b/tests/ui/chat/bottomLayoutShift.test.ts @@ -1,6 +1,6 @@ import "../dom"; -import { fireEvent, waitFor } from "@testing-library/react"; +import { waitFor } from "@testing-library/react"; // App-level UI tests render the loader shell first, so stub Lottie before importing the // harness to keep happy-dom from tripping over lottie-web's canvas bootstrap. @@ -125,130 +125,6 @@ describe("Chat bottom layout stability", () => { } }, 60_000); - test("disables browser scroll anchoring while auto-scroll owns the transcript tail", async () => { - const app = await createAppHarness({ branchPrefix: "streaming-barrier-anchor" }); - - try { - await app.chat.send("Seed transcript before testing scroll anchoring"); - await app.chat.expectStreamComplete(); - - const messageWindow = getMessageWindow(app.view.container); - let scrollTop = 900; - const scrollHeight = 1300; - const clientHeight = 400; - - Object.defineProperty(messageWindow, "scrollTop", { - configurable: true, - get: () => scrollTop, - set: (nextValue: number) => { - scrollTop = nextValue; - }, - }); - Object.defineProperty(messageWindow, "scrollHeight", { - configurable: true, - get: () => scrollHeight, - }); - Object.defineProperty(messageWindow, "clientHeight", { - configurable: true, - get: () => clientHeight, - }); - - await waitFor( - () => { - expect(messageWindow.style.overflowAnchor).toBe("none"); - }, - { timeout: 10_000 } - ); - - // Mark the scroll as user-driven, then move away from the bottom so ChatPane yields - // control back to the browser's default anchoring behavior while reading older content. - fireEvent.wheel(messageWindow); - fireEvent.scroll(messageWindow); - scrollTop = 600; - fireEvent.scroll(messageWindow); - - await waitFor(() => { - expect(messageWindow.style.overflowAnchor).toBe(""); - }); - - await app.chat.send("[mock:wait-start] Hold stream-start so the barrier stays mounted"); - - await waitFor( - () => { - const state = workspaceStore.getWorkspaceSidebarState(app.workspaceId); - if (!state.isStarting) { - throw new Error("Workspace is not in starting state yet"); - } - }, - { timeout: 10_000 } - ); - - await waitFor( - () => { - expect(messageWindow.style.overflowAnchor).toBe("none"); - }, - { timeout: 10_000 } - ); - - app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); - await app.chat.expectStreamComplete(); - } finally { - await app.dispose(); - } - }, 60_000); - - test("treats keyboard transcript scrolling as user-owned and disables auto-scroll", async () => { - const app = await createAppHarness({ branchPrefix: "keyboard-scroll-autoscroll" }); - - try { - await app.chat.send("Seed transcript before testing keyboard scroll ownership"); - await app.chat.expectStreamComplete(); - - const messageWindow = getMessageWindow(app.view.container); - let scrollTop = 900; - const scrollHeight = 1300; - const clientHeight = 400; - - Object.defineProperty(messageWindow, "scrollTop", { - configurable: true, - get: () => scrollTop, - set: (nextValue: number) => { - scrollTop = nextValue; - }, - }); - Object.defineProperty(messageWindow, "scrollHeight", { - configurable: true, - get: () => scrollHeight, - }); - Object.defineProperty(messageWindow, "clientHeight", { - configurable: true, - get: () => clientHeight, - }); - - await waitFor( - () => { - expect(messageWindow.style.overflowAnchor).toBe("none"); - }, - { timeout: 10_000 } - ); - - messageWindow.focus(); - fireEvent.keyDown(messageWindow, { key: "PageUp" }); - fireEvent.scroll(messageWindow); - scrollTop = 600; - fireEvent.scroll(messageWindow); - - await waitFor( - () => { - expect(messageWindow.style.overflowAnchor).toBe(""); - }, - { timeout: 10_000 } - ); - } finally { - await app.dispose(); - } - }, 60_000); - test("keeps the transcript pinned when send-time footer UI appears", async () => { const app = await createAppHarness({ branchPrefix: "bottom-layout-shift" }); diff --git a/tests/ui/chat/newChatStreamingFlash.test.ts b/tests/ui/chat/newChatStreamingFlash.test.ts new file mode 100644 index 0000000000..d52f8a5c03 --- /dev/null +++ b/tests/ui/chat/newChatStreamingFlash.test.ts @@ -0,0 +1,214 @@ +import "../dom"; + +// App-level UI tests render the creation splash first, so stub Lottie before importing the +// app harness pieces to keep happy-dom from tripping over lottie-web initialization. +jest.mock("lottie-react", () => ({ + __esModule: true, + default: () => null, +})); +import { waitFor } from "@testing-library/react"; + +import { preloadTestModules, createTestEnvironment, cleanupTestEnvironment } from "../../ipc/setup"; +import { createTempGitRepo, cleanupTempGitRepo, trustProject } from "../../ipc/helpers"; +import { + cleanupView, + addProjectViaUI, + openProjectCreationView, + setupTestDom, + waitForLatestDraftId, +} from "../helpers"; +import { renderApp, type RenderedApp } from "../renderReviewPanel"; +import { ChatHarness } from "../harness"; +import { workspaceStore } from "@/browser/stores/WorkspaceStore"; +import { getDraftScopeId } from "@/common/constants/storage"; +import type { TestEnvironment } from "../../ipc/setup"; + +interface CreationHarness { + env: TestEnvironment; + repoPath: string; + projectPath: string; + draftId: string; + view: RenderedApp; + chat: ChatHarness; + dispose(): Promise; +} + +async function createCreationHarness(options?: { + beforeRender?: (env: TestEnvironment) => void; +}): Promise { + const repoPath = await createTempGitRepo(); + const env = await createTestEnvironment(); + const cleanupDom = setupTestDom(); + + try { + env.services.aiService.enableMockMode(); + await trustProject(env, repoPath); + + options?.beforeRender?.(env); + const view = renderApp({ apiClient: env.orpc }); + const projectPath = await addProjectViaUI(view, repoPath); + await openProjectCreationView(view, projectPath); + const draftId = await waitForLatestDraftId(projectPath); + const chat = new ChatHarness(view.container, getDraftScopeId(projectPath, draftId)); + + return { + env, + repoPath, + projectPath, + draftId, + view, + chat, + async dispose() { + const workspaces = await env.orpc.workspace.list({ archived: false }).catch(() => []); + await Promise.all( + workspaces + .filter((workspace) => workspace.projectPath === projectPath) + .map((workspace) => + env.orpc.workspace.remove({ workspaceId: workspace.id, options: { force: true } }) + ) + ); + await cleanupView(view, cleanupDom); + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(repoPath); + }, + }; + } catch (error) { + cleanupDom(); + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(repoPath); + throw error; + } +} + +type WorkspaceSendMessageFn = TestEnvironment["orpc"]["workspace"]["sendMessage"]; + +function overrideWorkspaceSendMessage( + env: TestEnvironment, + override: WorkspaceSendMessageFn +): () => void { + const workspaceApi = env.orpc.workspace as typeof env.orpc.workspace & { + sendMessage: WorkspaceSendMessageFn; + }; + const originalSendMessage = workspaceApi.sendMessage; + workspaceApi.sendMessage = override; + return () => { + workspaceApi.sendMessage = originalSendMessage; + }; +} + +async function waitForCreatedWorkspaceId( + env: TestEnvironment, + projectPath: string +): Promise { + return waitFor( + async () => { + const workspaces = await env.orpc.workspace.list({ archived: false }); + const createdWorkspace = workspaces.find( + (workspace) => workspace.projectPath === projectPath + ); + if (!createdWorkspace) { + throw new Error("Created workspace not found yet"); + } + return createdWorkspace.id; + }, + { timeout: 10_000 } + ); +} + +describe("New chat streaming flash regression", () => { + beforeAll(async () => { + await preloadTestModules(); + }); + + test("new chats show the starting barrier instead of flashing empty transcript placeholders", async () => { + let releaseSend: () => void = () => {}; + const sendGate = new Promise((resolve) => { + releaseSend = () => resolve(); + }); + let restoreSendMessage: () => void = () => {}; + const app = await createCreationHarness({ + beforeRender: (env) => { + const originalSendMessage = env.orpc.workspace.sendMessage.bind( + env.orpc.workspace + ) as WorkspaceSendMessageFn; + restoreSendMessage = overrideWorkspaceSendMessage(env, (async (input) => { + await sendGate; + return originalSendMessage(input); + }) as WorkspaceSendMessageFn); + }, + }); + + let sawCatchingUpPlaceholder = false; + let sawNoMessagesYetPlaceholder = false; + let startedCreationSend = false; + const observer = new MutationObserver(() => { + if (!startedCreationSend) { + return; + } + const text = app.view.container.textContent ?? ""; + if (text.includes("Catching up with the agent...")) { + sawCatchingUpPlaceholder = true; + } + if (text.includes("No Messages Yet")) { + sawNoMessagesYetPlaceholder = true; + } + }); + observer.observe(app.view.container, { + childList: true, + subtree: true, + characterData: true, + }); + + try { + startedCreationSend = true; + await app.chat.send("Delay the very first send so the new chat view can settle"); + + const workspaceId = await waitForCreatedWorkspaceId(app.env, app.projectPath); + + await waitFor( + () => { + const messageWindow = app.view.container.querySelector('[data-testid="message-window"]'); + if (!messageWindow) { + throw new Error("Workspace chat view not rendered yet"); + } + }, + { timeout: 10_000 } + ); + + await waitFor( + () => { + const state = workspaceStore.getWorkspaceSidebarState(workspaceId); + if (!state.isStarting) { + throw new Error("Workspace has not entered the optimistic starting state yet"); + } + }, + { timeout: 10_000 } + ); + + await waitFor( + () => { + const text = app.view.container.textContent ?? ""; + expect(text.toLowerCase()).toContain("starting"); + expect(text).not.toContain("Catching up with the agent..."); + expect(text).not.toContain("No Messages Yet"); + }, + { timeout: 5_000 } + ); + + expect(sawCatchingUpPlaceholder).toBe(false); + expect(sawNoMessagesYetPlaceholder).toBe(false); + + releaseSend(); + const workspaceChat = new ChatHarness(app.view.container, workspaceId); + await workspaceChat.expectTranscriptContains( + "Mock response: Delay the very first send so the new chat view can settle" + ); + await workspaceChat.expectStreamComplete(); + } finally { + observer.disconnect(); + restoreSendMessage(); + releaseSend(); + await app.dispose(); + } + }, 60_000); +}); diff --git a/tests/ui/chat/streamInterrupt.test.ts b/tests/ui/chat/streamInterrupt.test.ts index b25667bad5..c448008136 100644 --- a/tests/ui/chat/streamInterrupt.test.ts +++ b/tests/ui/chat/streamInterrupt.test.ts @@ -11,6 +11,13 @@ */ import "../dom"; + +// App-level UI tests can hit loader shells first, so stub Lottie before importing the +// harness to keep happy-dom from tripping over lottie-web's canvas bootstrap. +jest.mock("lottie-react", () => ({ + __esModule: true, + default: () => null, +})); import { waitFor } from "@testing-library/react"; import { preloadTestModules } from "../../ipc/setup"; diff --git a/tests/ui/tasks/awaitVisualization.test.ts b/tests/ui/tasks/awaitVisualization.test.ts index 0aa72e635a..897dd5ef3a 100644 --- a/tests/ui/tasks/awaitVisualization.test.ts +++ b/tests/ui/tasks/awaitVisualization.test.ts @@ -104,7 +104,16 @@ describe("task_await executing visualization", () => { await setupWorkspaceView(view, createResult.metadata, workspaceId); await waitForWorkspaceChatToRender(view.container); - const toolName = view.getByText("task_await"); + const toolName = await waitFor( + () => { + const node = view?.queryByText("task_await"); + if (!node) { + throw new Error("task_await tool call has not hydrated yet"); + } + return node; + }, + { timeout: 30_000 } + ); toolName.click(); await waitFor(() => {