From cc8b7cffb3a6f4f3dd239594f02b895ea1adb75b Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 9 Apr 2026 11:40:17 -0500 Subject: [PATCH 01/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20prevent=20new=20cha?= =?UTF-8?q?t=20streaming=20flash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep a newly created workspace in an optimistic starting state until the first real send reaches onChat, suppressing the transient catch-up and empty-state placeholders that could flash during the handoff from project creation to the workspace chat. Add a focused regression test that delays the initial send and verifies the starting barrier stays visible throughout the transition. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$17.56`_ --- src/browser/components/ChatPane/ChatPane.tsx | 14 +- .../WorkspaceShell/WorkspaceShell.tsx | 6 +- .../ChatInput/useCreationWorkspace.test.tsx | 73 +++++- .../ChatInput/useCreationWorkspace.ts | 17 ++ src/browser/stores/WorkspaceStore.test.ts | 168 ++++++++++++++ src/browser/stores/WorkspaceStore.ts | 90 +++++++- tests/ui/chat/newChatStreamingFlash.test.ts | 214 ++++++++++++++++++ tests/ui/chat/streamInterrupt.test.ts | 7 + 8 files changed, 572 insertions(+), 17 deletions(-) create mode 100644 tests/ui/chat/newChatStreamingFlash.test.ts diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index b36f0b1661..98d4da4d2c 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -630,8 +630,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 +811,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 +822,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.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx index e08712942d..cc476d5b1c 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 || (workspaceState.loading && !workspaceState.isStreamStarting)) { return ( = (props) => { ); } + // 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: 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) { + if (workspaceState.isHydratingTranscript && !window.api && !workspaceState.isStreamStarting) { return ( = []; @@ -492,6 +493,9 @@ describe("useCreationWorkspace", () => { updatePersistedStateCalls.length = 0; draftSettingsInvocations = []; draftSettingsState = createDraftSettingsHarness(); + routerState.currentWorkspaceId = null; + routerState.currentProjectId = null; + routerState.pendingDraftId = null; }); afterEach(() => { @@ -915,6 +919,69 @@ 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 }) => ({ + metadata, + options, + }) + ); + const markPendingInitialSendSpy = spyOn(workspaceStore, "markPendingInitialSend"); + + 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 }); + expect(markPendingInitialSendSpy.mock.calls.length).toBe(0); + }); + test("handleSend surfaces backend errors and resets state", async () => { const createMock = mock( (_args: WorkspaceCreateArgs): Promise => @@ -1078,8 +1145,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..9aae65a0f3 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. @@ -560,6 +564,13 @@ export function useCreationWorkspace({ })(); onWorkspaceCreated(metadata, { autoNavigate: shouldAutoNavigate }); + if (shouldAutoNavigate) { + // User rationale: after creating a brand-new chat, keep the workspace in a visible + // "starting" state until onChat observes the first real user message or error. + // Background-created workspaces should skip this optimistic flag so they don't open later + // looking like a stale in-flight startup. + workspaceStore.markPendingInitialSend(metadata.id, baseModel); + } if (typeof draftId === "string" && draftId.trim().length > 0 && promoteWorkspaceDraft) { // UI-only: show the created workspace in-place where the draft was rendered. @@ -594,6 +605,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 +617,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/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index f62c795da9..cec016568a 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -1058,6 +1058,30 @@ describe("WorkspaceStore", () => { expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(true); }); + it("preserves optimistic initial-send 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; + chatTransientState: Map< + string, + { + pendingInitialSend: { pendingStreamModel: string | null } | null; + isHydratingTranscript: boolean; + } + >; + }; + + createAndAddWorkspace(store, workspaceId); + store.markPendingInitialSend(workspaceId, requestedModel); + + internalStore.resetChatStateForReplay(workspaceId); + + const transientState = internalStore.chatTransientState.get(workspaceId); + expect(transientState?.pendingInitialSend).toEqual({ pendingStreamModel: requestedModel }); + expect(store.getWorkspaceState(workspaceId).isStreamStarting).toBe(true); + }); + it("clears transcript hydration after repeated catch-up retry failures", async () => { const workspaceId = "workspace-hydration-retry-fallback"; let attempts = 0; @@ -1571,6 +1595,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 +1931,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 2124d6b453..9a0112031a 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -192,6 +192,10 @@ export interface AdvisorLivePhaseState { timestamp: number; } +interface PendingInitialSendState { + pendingStreamModel: string | null; +} + interface WorkspaceChatTransientState { caughtUp: boolean; isHydratingTranscript: boolean; @@ -203,6 +207,7 @@ interface WorkspaceChatTransientState { liveAdvisorPhase: Map; liveTaskIds: Map; autoRetryStatus: AutoRetryStatus | null; + pendingInitialSend: PendingInitialSendState | null; } interface HistoryPaginationCursor { @@ -254,6 +259,7 @@ function createInitialChatTransientState(): WorkspaceChatTransientState { liveAdvisorPhase: new Map(), liveTaskIds: new Map(), autoRetryStatus: null, + pendingInitialSend: null, }; } @@ -651,6 +657,10 @@ export class WorkspaceStore { "stream-abort": (workspaceId, aggregator, data) => { const streamAbortData = data as StreamAbortEvent; applyWorkspaceChatEventToAggregator(aggregator, streamAbortData); + const transient = this.assertChatTransientState(workspaceId); + if (transient.pendingInitialSend != null) { + transient.pendingInitialSend = null; + } // Track stream interruption telemetry (get model from aggregator) const model = aggregator.getCurrentModel(); @@ -1489,6 +1499,24 @@ export class WorkspaceStore { return state; } + private setPendingInitialSend(workspaceId: string, pendingStreamModel: string | null): void { + const transient = this.chatTransientState.get(workspaceId); + if (!transient) { + return; + } + transient.pendingInitialSend = { pendingStreamModel }; + this.states.bump(workspaceId); + } + + private clearPendingInitialSend(workspaceId: string): void { + const transient = this.chatTransientState.get(workspaceId); + if (transient?.pendingInitialSend == null) { + return; + } + transient.pendingInitialSend = null; + this.states.bump(workspaceId); + } + private deriveHistoryPaginationState( aggregator: StreamingMessageAggregator, hasOlderOverride?: boolean @@ -1582,20 +1610,23 @@ export class WorkspaceStore { streamLifecycle !== null && streamLifecycle.phase !== "idle"; const hasReplayPreparingLifecycle = isActiveWorkspace && !transient.caughtUp && streamLifecycle?.phase === "preparing"; + const optimisticPendingInitialSend = isActiveWorkspace ? transient.pendingInitialSend : 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. + // Treat the backend lifecycle as authoritative, but keep an optimistic pre-stream state + // for the active creation flow until onChat observes the first real user message/error. const isStreamStarting = - (useAggregatorState || hasReplayPreparingLifecycle) && + (useAggregatorState || + hasReplayPreparingLifecycle || + optimisticPendingInitialSend !== null) && (streamLifecycle?.phase === "preparing" || - (!hasAuthoritativeStreamLifecycle && pendingStreamStartTime !== null)) && + (!hasAuthoritativeStreamLifecycle && + (pendingStreamStartTime !== null || optimisticPendingInitialSend !== null))) && !canInterrupt; // 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 @@ -1645,7 +1676,8 @@ export class WorkspaceStore { lastAbortReason: aggregator.getLastAbortReason(), agentStatus, pendingStreamStartTime, - pendingStreamModel: aggregator.getPendingStreamModel(), + pendingStreamModel: + optimisticPendingInitialSend?.pendingStreamModel ?? aggregator.getPendingStreamModel(), autoRetryStatus: transient.autoRetryStatus, runtimeStatus: aggregator.getRuntimeStatus(), streamingTokenCount, @@ -2400,6 +2432,10 @@ export class WorkspaceStore { // cannot leak compaction metadata into future completion callbacks. this.aggregators.get(workspaceId)?.clearActiveStreams(); this.aggregators.get(workspaceId)?.clearPendingStreamStart(); + const transient = this.chatTransientState.get(workspaceId); + if (transient?.pendingInitialSend != null) { + transient.pendingInitialSend = null; + } } if (snapshot?.streaming !== true) { @@ -2848,6 +2884,9 @@ export class WorkspaceStore { if (previousTransient?.isHydratingTranscript) { nextTransient.isHydratingTranscript = true; } + // User rationale: optimistic first-send startup must survive replay resets because the + // initial full replay can happen before the first user turn is actually replayed. + nextTransient.pendingInitialSend = previousTransient?.pendingInitialSend ?? null; this.chatTransientState.set(workspaceId, nextTransient); @@ -3210,6 +3249,14 @@ export class WorkspaceStore { } } + markPendingInitialSend(workspaceId: string, pendingStreamModel: string | null): void { + this.setPendingInitialSend(workspaceId, pendingStreamModel); + } + + clearPendingInitialSendState(workspaceId: string): void { + this.clearPendingInitialSend(workspaceId); + } + /** * Remove a workspace and clean up subscriptions. */ @@ -3492,8 +3539,10 @@ 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, clear any stale aggregator-owned pending-start + // state so reconnects don't stay stuck in "starting...". Keep the optimistic initial-send + // flag until we actually observe the first turn (or a definitive background stop) because + // caught-up can arrive before the delayed first send is replayed. if (serverActiveStreamMessageId === undefined) { aggregator.clearPendingStreamStart(); } @@ -3525,9 +3574,18 @@ export class WorkspaceStore { if (transient.historicalMessages.length > 0) { const loadMode = replay === "full" ? "replace" : "append"; + const bufferedInitialUserMessage = + transient.pendingInitialSend !== null && + transient.historicalMessages.some((message) => message.role === "user"); aggregator.loadHistoricalMessages(transient.historicalMessages, hasActiveStream, { mode: loadMode, }); + // Keep the optimistic pending-start flag alive until caught-up applies the buffered + // first-turn history. Clearing it earlier would let generic loading placeholders win + // for one render before the replayed user message establishes the real startup state. + if (bufferedInitialUserMessage) { + transient.pendingInitialSend = null; + } transient.historicalMessages.length = 0; } else if (replay === "full") { // Full replay can legitimately contain zero messages (e.g. compacted to empty). @@ -3652,6 +3710,7 @@ export class WorkspaceStore { const allowSideEffects = !transient.replayingHistory; applyWorkspaceChatEventToAggregator(aggregator, data, { allowSideEffects }); + this.clearPendingInitialSend(workspaceId); this.states.bump(workspaceId); return; @@ -3734,6 +3793,9 @@ export class WorkspaceStore { // Buffer historical MuxMessages transient.historicalMessages.push(data); } else { + if (data.role === "user" && transient.pendingInitialSend !== null) { + transient.pendingInitialSend = null; + } // Process live events immediately (after history loaded) applyWorkspaceChatEventToAggregator(aggregator, data); @@ -3815,6 +3877,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/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"; From f96e4389445bde68460a2309e6e71e8757dde93e Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 9 Apr 2026 15:27:43 -0500 Subject: [PATCH 02/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20simplify=20new=20ch?= =?UTF-8?q?at=20startup=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the optimistic new-chat startup state into StreamingMessageAggregator so WorkspaceStore can derive starting directly from aggregator-owned pending stream state. This removes the extra transient pendingInitialSend bookkeeping while keeping the startup barrier alive through empty catch-up cycles and replay resets. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$46.60`_ --- src/browser/stores/WorkspaceStore.test.ts | 15 +-- src/browser/stores/WorkspaceStore.ts | 95 +++++-------------- .../StreamingMessageAggregator.test.ts | 35 +++++++ .../messages/StreamingMessageAggregator.ts | 54 ++++++++++- 4 files changed, 116 insertions(+), 83 deletions(-) diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index cec016568a..9c418aaa3f 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -1058,18 +1058,11 @@ describe("WorkspaceStore", () => { expect(store.getWorkspaceState(workspaceId).isHydratingTranscript).toBe(true); }); - it("preserves optimistic initial-send startup across full replay resets", () => { + 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; - chatTransientState: Map< - string, - { - pendingInitialSend: { pendingStreamModel: string | null } | null; - isHydratingTranscript: boolean; - } - >; }; createAndAddWorkspace(store, workspaceId); @@ -1077,9 +1070,9 @@ describe("WorkspaceStore", () => { internalStore.resetChatStateForReplay(workspaceId); - const transientState = internalStore.chatTransientState.get(workspaceId); - expect(transientState?.pendingInitialSend).toEqual({ pendingStreamModel: requestedModel }); - expect(store.getWorkspaceState(workspaceId).isStreamStarting).toBe(true); + 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 () => { diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 9a0112031a..c86652d091 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -192,10 +192,6 @@ export interface AdvisorLivePhaseState { timestamp: number; } -interface PendingInitialSendState { - pendingStreamModel: string | null; -} - interface WorkspaceChatTransientState { caughtUp: boolean; isHydratingTranscript: boolean; @@ -207,7 +203,6 @@ interface WorkspaceChatTransientState { liveAdvisorPhase: Map; liveTaskIds: Map; autoRetryStatus: AutoRetryStatus | null; - pendingInitialSend: PendingInitialSendState | null; } interface HistoryPaginationCursor { @@ -259,7 +254,6 @@ function createInitialChatTransientState(): WorkspaceChatTransientState { liveAdvisorPhase: new Map(), liveTaskIds: new Map(), autoRetryStatus: null, - pendingInitialSend: null, }; } @@ -657,10 +651,6 @@ export class WorkspaceStore { "stream-abort": (workspaceId, aggregator, data) => { const streamAbortData = data as StreamAbortEvent; applyWorkspaceChatEventToAggregator(aggregator, streamAbortData); - const transient = this.assertChatTransientState(workspaceId); - if (transient.pendingInitialSend != null) { - transient.pendingInitialSend = null; - } // Track stream interruption telemetry (get model from aggregator) const model = aggregator.getCurrentModel(); @@ -1499,24 +1489,6 @@ export class WorkspaceStore { return state; } - private setPendingInitialSend(workspaceId: string, pendingStreamModel: string | null): void { - const transient = this.chatTransientState.get(workspaceId); - if (!transient) { - return; - } - transient.pendingInitialSend = { pendingStreamModel }; - this.states.bump(workspaceId); - } - - private clearPendingInitialSend(workspaceId: string): void { - const transient = this.chatTransientState.get(workspaceId); - if (transient?.pendingInitialSend == null) { - return; - } - transient.pendingInitialSend = null; - this.states.bump(workspaceId); - } - private deriveHistoryPaginationState( aggregator: StreamingMessageAggregator, hasOlderOverride?: boolean @@ -1608,9 +1580,7 @@ export class WorkspaceStore { : (activity?.lastThinkingLevel ?? aggregator.getCurrentThinkingLevel() ?? null); const hasAuthoritativeStreamLifecycle = streamLifecycle !== null && streamLifecycle.phase !== "idle"; - const hasReplayPreparingLifecycle = - isActiveWorkspace && !transient.caughtUp && streamLifecycle?.phase === "preparing"; - const optimisticPendingInitialSend = isActiveWorkspace ? transient.pendingInitialSend : null; + const activePendingStreamStartTime = isActiveWorkspace ? pendingStreamStartTime : null; const aggregatorRecency = aggregator.getRecencyTimestamp(); const recencyTimestamp = aggregatorRecency === null @@ -1618,16 +1588,13 @@ export class WorkspaceStore { : Math.max(aggregatorRecency, activity?.recency ?? aggregatorRecency); // 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. - // Treat the backend lifecycle as authoritative, but keep an optimistic pre-stream state - // for the active creation flow until onChat observes the first real user message/error. + // 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 || - optimisticPendingInitialSend !== null) && + isActiveWorkspace && + !canInterrupt && (streamLifecycle?.phase === "preparing" || - (!hasAuthoritativeStreamLifecycle && - (pendingStreamStartTime !== null || optimisticPendingInitialSend !== 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. @@ -1676,8 +1643,7 @@ export class WorkspaceStore { lastAbortReason: aggregator.getLastAbortReason(), agentStatus, pendingStreamStartTime, - pendingStreamModel: - optimisticPendingInitialSend?.pendingStreamModel ?? aggregator.getPendingStreamModel(), + pendingStreamModel: aggregator.getPendingStreamModel(), autoRetryStatus: transient.autoRetryStatus, runtimeStatus: aggregator.getRuntimeStatus(), streamingTokenCount, @@ -2432,10 +2398,6 @@ export class WorkspaceStore { // cannot leak compaction metadata into future completion callbacks. this.aggregators.get(workspaceId)?.clearActiveStreams(); this.aggregators.get(workspaceId)?.clearPendingStreamStart(); - const transient = this.chatTransientState.get(workspaceId); - if (transient?.pendingInitialSend != null) { - transient.pendingInitialSend = null; - } } if (snapshot?.streaming !== true) { @@ -2873,7 +2835,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); @@ -2884,9 +2846,6 @@ export class WorkspaceStore { if (previousTransient?.isHydratingTranscript) { nextTransient.isHydratingTranscript = true; } - // User rationale: optimistic first-send startup must survive replay resets because the - // initial full replay can happen before the first user turn is actually replayed. - nextTransient.pendingInitialSend = previousTransient?.pendingInitialSend ?? null; this.chatTransientState.set(workspaceId, nextTransient); @@ -3250,11 +3209,23 @@ export class WorkspaceStore { } markPendingInitialSend(workspaceId: string, pendingStreamModel: string | null): void { - this.setPendingInitialSend(workspaceId, pendingStreamModel); + const aggregator = this.aggregators.get(workspaceId); + if (!aggregator) { + return; + } + + aggregator.markOptimisticPendingStreamStart(pendingStreamModel); + this.states.bump(workspaceId); } clearPendingInitialSendState(workspaceId: string): void { - this.clearPendingInitialSend(workspaceId); + const aggregator = this.aggregators.get(workspaceId); + if (aggregator?.getPendingStreamStartTime() == null) { + return; + } + + aggregator.clearPendingStreamStart(); + this.states.bump(workspaceId); } /** @@ -3539,12 +3510,11 @@ export class WorkspaceStore { ) { aggregator.clearActiveStreams(); } - // When server confirms no active stream, clear any stale aggregator-owned pending-start - // state so reconnects don't stay stuck in "starting...". Keep the optimistic initial-send - // flag until we actually observe the first turn (or a definitive background stop) because - // caught-up can arrive before the delayed first send is replayed. + // 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") { @@ -3574,18 +3544,9 @@ export class WorkspaceStore { if (transient.historicalMessages.length > 0) { const loadMode = replay === "full" ? "replace" : "append"; - const bufferedInitialUserMessage = - transient.pendingInitialSend !== null && - transient.historicalMessages.some((message) => message.role === "user"); aggregator.loadHistoricalMessages(transient.historicalMessages, hasActiveStream, { mode: loadMode, }); - // Keep the optimistic pending-start flag alive until caught-up applies the buffered - // first-turn history. Clearing it earlier would let generic loading placeholders win - // for one render before the replayed user message establishes the real startup state. - if (bufferedInitialUserMessage) { - transient.pendingInitialSend = null; - } transient.historicalMessages.length = 0; } else if (replay === "full") { // Full replay can legitimately contain zero messages (e.g. compacted to empty). @@ -3710,7 +3671,6 @@ export class WorkspaceStore { const allowSideEffects = !transient.replayingHistory; applyWorkspaceChatEventToAggregator(aggregator, data, { allowSideEffects }); - this.clearPendingInitialSend(workspaceId); this.states.bump(workspaceId); return; @@ -3793,9 +3753,6 @@ export class WorkspaceStore { // Buffer historical MuxMessages transient.historicalMessages.push(data); } else { - if (data.role === "user" && transient.pendingInitialSend !== null) { - transient.pendingInitialSend = null; - } // Process live events immediately (after history loaded) applyWorkspaceChatEventToAggregator(aggregator, data); 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) { From 7186db38ccb3ec925f4add4dd7a894a41cef491a Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 10 Apr 2026 21:11:08 -0500 Subject: [PATCH 03/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20cached=20tra?= =?UTF-8?q?nscript=20visible=20during=20workspace=20hydration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop the web-only WorkspaceShell catch-up splash from hiding cached transcript content when switching between existing chats. If the target workspace already has transcript rows or a queued draft, keep that content mounted during the onChat replay handoff instead of flashing it away behind the generic hydration placeholder. Adds a focused WorkspaceShell regression test for the cached-content case and keeps the existing generic hydration/loading placeholder coverage. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$69.76`_ --- .../WorkspaceShell/WorkspaceShell.test.tsx | 37 ++++++++++++++++++- .../WorkspaceShell/WorkspaceShell.tsx | 14 ++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx index 6e81a28ff1..0226a8691b 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", () => ({ @@ -137,6 +157,21 @@ describe("WorkspaceShell loading placeholders", () => { expect(view.getByTestId("lottie-animation")).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("renders loading animation during workspace loading", () => { workspaceState = { loading: true, diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx index cc476d5b1c..74b6ea3259 100644 --- a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx +++ b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx @@ -192,12 +192,22 @@ export const WorkspaceShell: React.FC = (props) => { ); } + const hasCachedWorkspaceContent = + workspaceState.messages.length > 0 || workspaceState.queuedMessage !== null; + // 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: during workspace switches, the WebSocket subscription needs time to - // catch up. Show a splash instead of flashing stale cached messages. + // catch up. Only fall back to the generic splash when there is no cached transcript + // or queued draft to keep visible; otherwise preserve the current workspace content + // during the replay handoff so transcript switches do not flash away. // Electron's MessageChannel is near-instant so this gate is unnecessary there. - if (workspaceState.isHydratingTranscript && !window.api && !workspaceState.isStreamStarting) { + if ( + workspaceState.isHydratingTranscript && + !window.api && + !workspaceState.isStreamStarting && + !hasCachedWorkspaceContent + ) { return ( Date: Sun, 12 Apr 2026 11:41:40 -0500 Subject: [PATCH 04/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20eliminate=20workspa?= =?UTF-8?q?ce=20switch=20transcript=20tear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the ChatPane viewport mounted across workspace switches so the transcript doesn't visibly tear while the next workspace hydrates. ChatPane now resets the per-workspace local UI state that used to be reset by the root remount, and it re-arms tail ownership on workspace changes so the incoming transcript stays pinned instead of inheriting stale scroll state. Adds a focused WorkspaceShell regression asserting that the chat pane DOM node survives workspace switches, alongside the existing UI regressions for transcript pinning and new-chat hydration. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$73.46`_ --- src/browser/components/ChatPane/ChatPane.tsx | 26 ++++++++++++------- .../WorkspaceShell/WorkspaceShell.test.tsx | 26 +++++++++++++++++++ .../WorkspaceShell/WorkspaceShell.tsx | 5 ++-- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 98d4da4d2c..eb0e389176 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -424,11 +424,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 +611,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). diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx index 0226a8691b..cfba80e721 100644 --- a/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx +++ b/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx @@ -172,6 +172,32 @@ describe("WorkspaceShell loading placeholders", () => { 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", () => { workspaceState = { loading: true, diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx index 74b6ea3259..7e32587bbd 100644 --- a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx +++ b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx @@ -236,9 +236,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. */} Date: Sun, 12 Apr 2026 16:40:06 -0500 Subject: [PATCH 05/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20stale=20def?= =?UTF-8?q?erred=20transcript=20frames=20on=20chat=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make deferred transcript rendering workspace-aware so chat switches cannot briefly render a stale deferred snapshot from the previous workspace. ChatPane now defers a workspace-scoped transcript snapshot instead of just the message array, and the deferred-message guard immediately falls back to the live snapshot when the deferred rows still belong to another workspace. Adds a regression unit test for the cross-workspace deferred snapshot case and reruns the switch-sensitive UI coverage. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$117.90`_ --- src/browser/components/ChatPane/ChatPane.tsx | 20 +++++++++++++++---- .../utils/messages/messageUtils.test.ts | 9 +++++++++ src/browser/utils/messages/messageUtils.ts | 14 ++++++++++++- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index eb0e389176..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( diff --git a/src/browser/utils/messages/messageUtils.test.ts b/src/browser/utils/messages/messageUtils.test.ts index a045e3625f..172628624c 100644 --- a/src/browser/utils/messages/messageUtils.test.ts +++ b/src/browser/utils/messages/messageUtils.test.ts @@ -171,6 +171,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 73abde7d9a..a32da685bd 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) => From f46a923c9156cfeb0b45186338239b6a2710ac72 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Apr 2026 10:19:10 -0500 Subject: [PATCH 06/20] =?UTF-8?q?=F0=9F=A4=96=20tests:=20add=20web=20works?= =?UTF-8?q?pace-switch=20tear=20repro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a browser-mode repro script that boots an isolated dev-server, creates two live mock-chat workspaces, switches between them, and captures screenshot/scroll diagnostics. The script exits non-zero when the target transcript keeps shifting after it is already visible, matching the user-reported same-transcript tear. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$121.96`_ --- scripts/reproWorkspaceSwitchTearWeb.ts | 458 +++++++++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 scripts/reproWorkspaceSwitchTearWeb.ts diff --git a/scripts/reproWorkspaceSwitchTearWeb.ts b/scripts/reproWorkspaceSwitchTearWeb.ts new file mode 100644 index 0000000000..3064f0cb82 --- /dev/null +++ b/scripts/reproWorkspaceSwitchTearWeb.ts @@ -0,0 +1,458 @@ +#!/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 appears to already contain the target workspace transcript. + * + * 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. Switches between them while capturing transcript screenshots. + * 4. Exits with code 1 when the target transcript keeps shifting after it is visible. + */ +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; +} + +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"); +} + +async function waitForMockResponse(page: Page, marker: string): Promise { + await page.waitForFunction( + (marker: string) => (document.body.textContent ?? "").includes(`Mock response: ${marker}`), + marker, + { timeout: 60_000 } + ); + await page.waitForTimeout(500); +} + +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 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 } + ); + + 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), + }, + }; + 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; + + 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, + }, + }, + null, + 2 + ) + ); + + process.exitCode = reproduced ? 1 : 0; + } finally { + await browser.close(); + } + } finally { + terminateServer(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 2; +}); From 198843241f6e5523a7abc96f84f834397e0311a0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Apr 2026 11:52:27 -0500 Subject: [PATCH 07/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stabilize=20chat=20?= =?UTF-8?q?switch=20handoffs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist in-flight mock assistant parts to partial.json so browser workspace switches can reopen a mid-stream mock turn from authoritative partial state instead of an empty placeholder, and tighten the browser repro to wait for a settled completed assistant row before measuring switch-time transcript stability. Also move optimistic pending-start marking into the state update that actually wins workspace selection so background-created workspaces do not inherit a stale "starting" barrier. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `$136.74`_ --- scripts/reproWorkspaceSwitchTearWeb.ts | 13 +- src/browser/App.tsx | 13 +- src/browser/features/ChatInput/types.ts | 2 + .../ChatInput/useCreationWorkspace.test.tsx | 80 ++++++- .../ChatInput/useCreationWorkspace.ts | 12 +- .../services/mock/mockAiStreamPlayer.test.ts | 109 +++++++++ src/node/services/mock/mockAiStreamPlayer.ts | 206 +++++++++++++++++- 7 files changed, 415 insertions(+), 20 deletions(-) diff --git a/scripts/reproWorkspaceSwitchTearWeb.ts b/scripts/reproWorkspaceSwitchTearWeb.ts index 3064f0cb82..1f18e70c40 100644 --- a/scripts/reproWorkspaceSwitchTearWeb.ts +++ b/scripts/reproWorkspaceSwitchTearWeb.ts @@ -167,13 +167,22 @@ async function sendMessage(page: Page, text: string): Promise { 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) => (document.body.textContent ?? "").includes(`Mock response: ${marker}`), + (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 } ); - await page.waitForTimeout(500); } async function captureSwitch(args: { diff --git a/src/browser/App.tsx b/src/browser/App.tsx index d250543ba5..4e118dc007 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -1163,13 +1163,20 @@ 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) 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; } + + // Keep the optimistic pending-start marker inside the same state update + // that wins workspace selection so queued selection changes in the same + // batch cannot leave a background-created workspace looking "starting". + workspaceStore.markPendingInitialSend( + metadata.id, + options?.pendingStreamModel ?? null + ); return toWorkspaceSelection(metadata); }); } diff --git a/src/browser/features/ChatInput/types.ts b/src/browser/features/ChatInput/types.ts index 5667bfb9b7..a2cc275d24 100644 --- a/src/browser/features/ChatInput/types.ts +++ b/src/browser/features/ChatInput/types.ts @@ -18,6 +18,8 @@ export interface ChatInputAPI { export interface WorkspaceCreatedOptions { /** When false, register metadata without navigating to the new workspace. */ autoNavigate?: boolean; + /** Pending model for the optimistic startup barrier when navigation actually occurs. */ + pendingStreamModel?: string | null; } // Workspace variant: full functionality for existing workspaces diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index 645b7c9fbb..8cacf4e52a 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -27,7 +27,6 @@ import type { import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { GlobalWindow } from "happy-dom"; -import { workspaceStore } from "@/browser/stores/WorkspaceStore"; import { useCreationWorkspace, type CreationSendResult } from "./useCreationWorkspace"; const readPersistedStateCalls: Array<[string, unknown]> = []; @@ -955,12 +954,14 @@ describe("useCreationWorkspace", () => { draftSettingsState = createDraftSettingsHarness({ trunkBranch: "main" }); routerState.pendingDraftId = "different-draft"; const onWorkspaceCreated = mock( - (metadata: FrontendWorkspaceMetadata, options?: { autoNavigate?: boolean }) => ({ + ( + metadata: FrontendWorkspaceMetadata, + options?: { autoNavigate?: boolean; pendingStreamModel?: string | null } + ) => ({ metadata, options, }) ); - const markPendingInitialSendSpy = spyOn(workspaceStore, "markPendingInitialSend"); const getHook = renderUseCreationWorkspace({ projectPath: TEST_PROJECT_PATH, @@ -978,8 +979,77 @@ describe("useCreationWorkspace", () => { expect(handleSendResult).toEqual({ success: true }); expect(onWorkspaceCreated.mock.calls.length).toBe(1); - expect(onWorkspaceCreated.mock.calls[0][1]).toEqual({ autoNavigate: false }); - expect(markPendingInitialSendSpy.mock.calls.length).toBe(0); + 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 () => { diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 9aae65a0f3..c17d9da15c 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -563,14 +563,10 @@ export function useCreationWorkspace({ return latestRoute.pendingDraftId === draftId; })(); - onWorkspaceCreated(metadata, { autoNavigate: shouldAutoNavigate }); - if (shouldAutoNavigate) { - // User rationale: after creating a brand-new chat, keep the workspace in a visible - // "starting" state until onChat observes the first real user message or error. - // Background-created workspaces should skip this optimistic flag so they don't open later - // looking like a stale in-flight startup. - workspaceStore.markPendingInitialSend(metadata.id, baseModel); - } + 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. diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index 5ff4971419..fb7ddf45ba 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; @@ -152,6 +179,88 @@ 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); + + player.stop(workspaceId); + await waitForCondition( + async () => (await historyService.readPartial(workspaceId)) === null, + 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(); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 66e52a7a94..2a1fcd7a7b 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; @@ -223,6 +231,13 @@ export class MockAiStreamPlayer { active.cancelled = true; + // User-initiated mock interrupts should not leave behind resumable partial state. + void this.deps.historyService.deletePartial(workspaceId).then((result) => { + if (!result.success) { + log.error(`Failed to clear mock partial on stop for ${active.messageId}: ${result.error}`); + } + }); + // Emit stream-abort event to mirror real streaming behavior this.deps.aiService.emit("stream-abort", { type: "stream-abort", @@ -381,6 +396,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 +444,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 +621,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 +638,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 +658,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 +686,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 +708,17 @@ 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}`); + } this.deps.aiService.emit( "error", createErrorEvent(workspaceId, { @@ -543,6 +731,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 +744,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 +758,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 +775,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 +795,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); From 9960d9dce337d2a2905da8875ff80d767729c770 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Apr 2026 16:57:49 -0500 Subject: [PATCH 08/20] fix workspace hydration composer tear --- scripts/reproWorkspaceSwitchTearWeb.ts | 151 +++++++++++++++++- .../WorkspaceShell/WorkspaceShell.test.tsx | 21 ++- .../WorkspaceShell/WorkspaceShell.tsx | 22 ++- 3 files changed, 174 insertions(+), 20 deletions(-) diff --git a/scripts/reproWorkspaceSwitchTearWeb.ts b/scripts/reproWorkspaceSwitchTearWeb.ts index 1f18e70c40..4db4f7548b 100644 --- a/scripts/reproWorkspaceSwitchTearWeb.ts +++ b/scripts/reproWorkspaceSwitchTearWeb.ts @@ -5,13 +5,15 @@ * * Why this exists: * - The remaining artifact was reported in the web/dev-server path, not Electron. - * - The visible tear appears to already contain the target workspace transcript. + * - 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. Switches between them while capturing transcript screenshots. - * 4. Exits with code 1 when the target transcript keeps shifting after it is visible. + * 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"; @@ -41,6 +43,18 @@ interface SwitchFrameSample { 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}]]`; } @@ -185,6 +199,65 @@ async function waitForMockResponse(page: Page, marker: string): Promise { ); } +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; @@ -238,6 +311,30 @@ async function captureSwitch(args: { 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 []; @@ -408,6 +505,36 @@ async function main() { { 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, @@ -421,6 +548,14 @@ async function main() { 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)); @@ -429,7 +564,9 @@ async function main() { result.firstDirection.geometryShifts.length > 0 || result.firstDirection.unstableVisualDiffs.length > 0 || result.secondDirection.geometryShifts.length > 0 || - result.secondDirection.unstableVisualDiffs.length > 0; + result.secondDirection.unstableVisualDiffs.length > 0 || + result.firstOpenAfterReload.inputDisappearances.length > 0 || + result.firstSwitchToUnseenAfterReload.inputDisappearances.length > 0; console.log( JSON.stringify( @@ -446,6 +583,12 @@ async function main() { geometryShifts: result.secondDirection.geometryShifts, unstableVisualDiffs: result.secondDirection.unstableVisualDiffs, }, + firstOpenAfterReload: { + inputDisappearances: result.firstOpenAfterReload.inputDisappearances, + }, + firstSwitchToUnseenAfterReload: { + inputDisappearances: result.firstSwitchToUnseenAfterReload.inputDisappearances, + }, }, null, 2 diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx index cfba80e721..db91e84ae3 100644 --- a/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx +++ b/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx @@ -145,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, @@ -153,8 +153,21 @@ 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", () => { @@ -198,7 +211,7 @@ describe("WorkspaceShell loading placeholders", () => { 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 7e32587bbd..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 && !workspaceState.isStreamStarting)) { + if (!workspaceState) { return ( = (props) => { ); } - const hasCachedWorkspaceContent = - workspaceState.messages.length > 0 || workspaceState.queuedMessage !== null; + 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: during workspace switches, the WebSocket subscription needs time to - // catch up. Only fall back to the generic splash when there is no cached transcript - // or queued draft to keep visible; otherwise preserve the current workspace content - // during the replay handoff so transcript switches do not flash away. - // Electron's MessageChannel is near-instant so this gate is unnecessary there. + // 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.isHydratingTranscript && - !window.api && + workspaceState.loading && !workspaceState.isStreamStarting && - !hasCachedWorkspaceContent + !shouldKeepChatPaneMountedDuringHydration ) { return ( From d01b2d2cd440afeb6cc1aba1e0ff1f9ee858f8ee Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Apr 2026 17:04:39 -0500 Subject: [PATCH 09/20] tests wait for task await hydration --- tests/ui/tasks/awaitVisualization.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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(() => { From 24827b540c5eb175c0e49c0877c4ae7d5de2cfae Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Apr 2026 17:43:24 -0500 Subject: [PATCH 10/20] tests stabilize auto scroll ownership coverage --- src/browser/hooks/useAutoScroll.test.tsx | 101 ++++++++++++++++++ tests/ui/chat/bottomLayoutShift.test.ts | 126 +---------------------- 2 files changed, 102 insertions(+), 125 deletions(-) create mode 100644 src/browser/hooks/useAutoScroll.test.tsx 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/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" }); From b6dcd44c2c5464777afb845c3f4b6293116ca0a6 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Apr 2026 17:56:56 -0500 Subject: [PATCH 11/20] refactor pending start selection gating --- src/browser/App.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 4e118dc007..1fda3782fc 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -1163,6 +1163,7 @@ function AppInner() { setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata)); if (options?.autoNavigate !== false) { + let createdSelection: WorkspaceSelection | null = null; setSelectedWorkspace((current) => { if (current !== null) { // If the user picked another workspace before create/send resolved, @@ -1170,15 +1171,20 @@ function AppInner() { return current; } - // Keep the optimistic pending-start marker inside the same state update - // that wins workspace selection so queued selection changes in the same - // batch cannot leave a background-created workspace looking "starting". + 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 ); - return toWorkspaceSelection(metadata); - }); + } } // Track telemetry From 388e8e3d3b4b7eaa09051f4d8a6ba395d87a2af0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Apr 2026 18:13:07 -0500 Subject: [PATCH 12/20] fix mock stream replacement partial cleanup --- .../services/mock/mockAiStreamPlayer.test.ts | 65 +++++++++++++++++++ src/node/services/mock/mockAiStreamPlayer.ts | 28 ++++---- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index fb7ddf45ba..0efa9a9c5e 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -228,6 +228,71 @@ describe("MockAiStreamPlayer", () => { ); }); + 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); + + player.stop(workspaceId); + await waitForCondition( + async () => (await historyService.readPartial(workspaceId)) === null, + 1500 + ); + }); + test("commits the full assistant message and clears partial state on stream end", async () => { const aiServiceStub = new EventEmitter(); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 2a1fcd7a7b..550469726b 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -225,20 +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; - // User-initiated mock interrupts should not leave behind resumable partial state. - void this.deps.historyService.deletePartial(workspaceId).then((result) => { - if (!result.success) { - log.error(`Failed to clear mock partial on stop for ${active.messageId}: ${result.error}`); - } - }); - - // 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, @@ -247,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}` + ); + } + } + + stop(workspaceId: string): void { + void this.stopActiveStream(workspaceId); } async play( @@ -367,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); From 7183d81f1f355dbe2e1cc5e429c79117980c5004 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Apr 2026 18:26:45 -0500 Subject: [PATCH 13/20] fix stale mock stream error replay --- .../services/mock/mockAiStreamPlayer.test.ts | 65 +++++++++++++++++++ src/node/services/mock/mockAiStreamPlayer.ts | 7 ++ 2 files changed, 72 insertions(+) diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index 0efa9a9c5e..4855346b82 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -293,6 +293,71 @@ describe("MockAiStreamPlayer", () => { ); }); + 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); + + 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(); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 550469726b..f6cb79203e 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -725,6 +725,13 @@ export class MockAiStreamPlayer { 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, { From 094f8ef54b93862c25b75feca26a357509c18875 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 13 Apr 2026 18:40:22 -0500 Subject: [PATCH 14/20] fix async mock stop cleanup ordering --- src/node/services/aiService.ts | 2 +- src/node/services/mock/mockAiStreamPlayer.test.ts | 12 ++++++------ src/node/services/mock/mockAiStreamPlayer.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index c62fd42718..73e649a154 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -2051,7 +2051,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 4855346b82..be8777ce78 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -77,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); @@ -112,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 () => { @@ -221,7 +221,7 @@ describe("MockAiStreamPlayer", () => { expect(partial?.id).toMatch(/^msg-mock-/); expect(extractText(partial).length).toBeGreaterThan(0); - player.stop(workspaceId); + await player.stop(workspaceId); await waitForCondition( async () => (await historyService.readPartial(workspaceId)) === null, 1000 @@ -286,7 +286,7 @@ describe("MockAiStreamPlayer", () => { expect(replacementPartial).not.toBeNull(); expect(replacementPartial?.id).not.toBe(firstPartial?.id); - player.stop(workspaceId); + await player.stop(workspaceId); await waitForCondition( async () => (await historyService.readPartial(workspaceId)) === null, 1500 @@ -354,7 +354,7 @@ describe("MockAiStreamPlayer", () => { expect(errorEvents).toHaveLength(0); - player.stop(workspaceId); + await player.stop(workspaceId); await waitForCondition(() => !player.isStreaming(workspaceId), 1000); }); @@ -424,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 f6cb79203e..7a9be415f5 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -250,8 +250,8 @@ export class MockAiStreamPlayer { } } - stop(workspaceId: string): void { - void this.stopActiveStream(workspaceId); + async stop(workspaceId: string): Promise { + await this.stopActiveStream(workspaceId); } async play( From e4253b96f6425bc9a1bd6af8e969949f1b3e8602 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Apr 2026 10:26:21 -0500 Subject: [PATCH 15/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20WorkspaceShe?= =?UTF-8?q?ll=20mounted=20during=20Electron=20hydration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the hydration keep-mounted path to Electron so opening an unseen workspace does not swap the whole shell back to the loading placeholder and drop the composer. Add a regression test that exercises the same loading state with window.api present. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `3904855{MUX_COSTS_USD}`_ --- .../WorkspaceShell/WorkspaceShell.test.tsx | 23 +++++++++++++++++++ .../WorkspaceShell/WorkspaceShell.tsx | 10 ++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx index db91e84ae3..0aa2f663b9 100644 --- a/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx +++ b/src/browser/components/WorkspaceShell/WorkspaceShell.test.tsx @@ -14,6 +14,7 @@ interface MockWorkspaceState { let cleanupDom: (() => void) | null = null; let workspaceState: MockWorkspaceState | undefined; +let originalWindowApi: WindowApi | undefined; const openTerminalMock = mock(() => Promise.resolve()); const addReviewMock = mock(() => undefined); @@ -132,6 +133,8 @@ describe("estimateWorkspaceShellFallbackWidthPx", () => { describe("WorkspaceShell loading placeholders", () => { beforeEach(() => { cleanupDom = installDom(); + originalWindowApi = window.api; + delete window.api; workspaceState = undefined; }); @@ -140,6 +143,12 @@ describe("WorkspaceShell loading placeholders", () => { mock.restore(); cleanupDom?.(); cleanupDom = null; + if (originalWindowApi === undefined) { + delete window.api; + } else { + window.api = originalWindowApi; + } + originalWindowApi = undefined; workspaceState = undefined; openTerminalMock.mockClear(); addReviewMock.mockClear(); @@ -170,6 +179,20 @@ describe("WorkspaceShell loading placeholders", () => { expect(view.getByTestId("chat-pane")).toBeTruthy(); }); + it("keeps the chat pane mounted during initial Electron workspace loading", () => { + window.api = { platform: "linux", versions: {} }; + 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, diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx index 3d164e3e27..071fdfe578 100644 --- a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx +++ b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx @@ -193,14 +193,14 @@ export const WorkspaceShell: React.FC = (props) => { } const shouldKeepChatPaneMountedDuringHydration = - !window.api && workspaceState.isHydratingTranscript && !workspaceState.isStreamStarting; + 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. + // 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 in both browser and + // Electron repros when an unseen workspace is opened. if ( workspaceState.loading && !workspaceState.isStreamStarting && From 34d024acfd87d4d8d21a7c7dc98bf5175817a7ab Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Apr 2026 10:52:53 -0500 Subject: [PATCH 16/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stabilize=20workspa?= =?UTF-8?q?ce=20hydration=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold the chat composer region at its previous height while a workspace transcript hydrates so workspace-specific decorations above the input do not collapse and re-expand during Electron switches. Also guard mock stream-error and stream-end partial cleanup against replacement-stream races before deleting shared partial state, and add regression coverage for both hydration height holding and stale cleanup behavior. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `63727{MUX_COSTS_USD}`_ --- src/browser/components/ChatPane/ChatPane.tsx | 12 +- .../ChatPane/HydrationStablePane.test.tsx | 150 ++++++++++++++++++ .../ChatPane/HydrationStablePane.tsx | 65 ++++++++ .../services/mock/mockAiStreamPlayer.test.ts | 64 ++++++++ src/node/services/mock/mockAiStreamPlayer.ts | 16 +- 5 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 src/browser/components/ChatPane/HydrationStablePane.test.tsx create mode 100644 src/browser/components/ChatPane/HydrationStablePane.tsx diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index d809c30063..7bbc61727a 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -19,6 +19,7 @@ import { EditCutoffBarrier } from "@/browser/features/Messages/ChatBarrier/EditC import { StreamingBarrier } from "@/browser/features/Messages/ChatBarrier/StreamingBarrier"; import { RetryBarrier } from "@/browser/features/Messages/ChatBarrier/RetryBarrier"; import { PinnedTodoList } from "../PinnedTodoList/PinnedTodoList"; +import { HydrationStablePane } from "./HydrationStablePane"; import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { ChatInput, type ChatInputAPI } from "@/browser/features/ChatInput/index"; import type { QueueDispatchMode } from "@/browser/features/ChatInput/types"; @@ -1018,6 +1019,7 @@ export const ChatPane: React.FC = (props) => { projectName={projectName} workspaceName={workspaceName} isStreamStarting={isStreamStarting} + isHydratingTranscript={isHydratingTranscript} runtimeConfig={runtimeConfig} isQueuedAgentTask={isQueuedAgentTask} isCompacting={isCompacting} @@ -1069,6 +1071,7 @@ interface ChatInputPaneProps { isQueuedAgentTask: boolean; isCompacting: boolean; isStreamStarting: boolean; + isHydratingTranscript: boolean; canInterrupt: boolean; autoCompactionResult: ReturnType; shouldShowCompactionWarning: boolean; @@ -1093,7 +1096,12 @@ const ChatInputPane: React.FC = (props) => { const { reviews } = props; return ( -
+ {/* Keep optional banners/warnings on one shared stack so spacing above the chat input stays consistent as background bash, review, queued-send, and TODO UI appear or disappear. @@ -1156,6 +1164,6 @@ const ChatInputPane: React.FC = (props) => { onDeleteReview={reviews.removeReview} onUpdateReviewNote={reviews.updateReviewNote} /> -
+ ); }; diff --git a/src/browser/components/ChatPane/HydrationStablePane.test.tsx b/src/browser/components/ChatPane/HydrationStablePane.test.tsx new file mode 100644 index 0000000000..34fac9c8bd --- /dev/null +++ b/src/browser/components/ChatPane/HydrationStablePane.test.tsx @@ -0,0 +1,150 @@ +import "../../../../tests/ui/dom"; + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { installDom } from "../../../../tests/ui/dom"; + +import { HydrationStablePane } from "./HydrationStablePane"; + +let cleanupDom: (() => void) | null = null; +let originalResizeObserver: typeof ResizeObserver | undefined; +const resizeCallbacks = new Map(); + +class ResizeObserverMock { + private readonly callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe(target: Element) { + resizeCallbacks.set(target, [...(resizeCallbacks.get(target) ?? []), this.callback]); + } + + unobserve(target: Element) { + const remainingCallbacks = (resizeCallbacks.get(target) ?? []).filter( + (callback) => callback !== this.callback + ); + if (remainingCallbacks.length === 0) { + resizeCallbacks.delete(target); + return; + } + resizeCallbacks.set(target, remainingCallbacks); + } + + disconnect() { + for (const [target, callbacks] of resizeCallbacks) { + const remainingCallbacks = callbacks.filter((callback) => callback !== this.callback); + if (remainingCallbacks.length === 0) { + resizeCallbacks.delete(target); + continue; + } + resizeCallbacks.set(target, remainingCallbacks); + } + } + + takeRecords(): ResizeObserverEntry[] { + return []; + } +} + +function emitResize(target: Element, height: number) { + const contentRect = { + x: 0, + y: 0, + width: 0, + height, + top: 0, + right: 0, + bottom: height, + left: 0, + toJSON: () => ({}), + } satisfies DOMRectReadOnly; + const entry: ResizeObserverEntry = { + target, + contentRect, + borderBoxSize: [] as unknown as readonly ResizeObserverSize[], + contentBoxSize: [] as unknown as readonly ResizeObserverSize[], + devicePixelContentBoxSize: [] as unknown as readonly ResizeObserverSize[], + }; + + for (const callback of resizeCallbacks.get(target) ?? []) { + callback([entry], {} as ResizeObserver); + } +} + +describe("HydrationStablePane", () => { + beforeEach(() => { + cleanupDom = installDom(); + originalResizeObserver = globalThis.ResizeObserver; + ( + globalThis as unknown as { + ResizeObserver: typeof ResizeObserver; + } + ).ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + resizeCallbacks.clear(); + }); + + afterEach(() => { + cleanup(); + resizeCallbacks.clear(); + if (originalResizeObserver === undefined) { + delete (globalThis as Partial).ResizeObserver; + } else { + ( + globalThis as unknown as { + ResizeObserver: typeof ResizeObserver; + } + ).ResizeObserver = originalResizeObserver; + } + cleanupDom?.(); + cleanupDom = null; + originalResizeObserver = undefined; + }); + + it("holds the last measured height while switching to a hydrating workspace", async () => { + const view = render( + +
workspace A
+
+ ); + + const pane = view.container.querySelector('[data-component="stable-pane"]'); + expect(pane).toBeTruthy(); + if (!pane) { + throw new Error("Expected pane to exist"); + } + + await waitFor(() => { + const callbacks = resizeCallbacks.get(pane); + if (!callbacks || callbacks.length === 0) { + throw new Error("Resize observer is not attached yet"); + } + }); + emitResize(pane, 184); + + view.rerender( + +
workspace B
+
+ ); + + expect((pane as HTMLDivElement).style.minHeight).toBe("184px"); + + view.rerender( + +
workspace B
+
+ ); + + expect((pane as HTMLDivElement).style.minHeight).toBe(""); + }); +}); diff --git a/src/browser/components/ChatPane/HydrationStablePane.tsx b/src/browser/components/ChatPane/HydrationStablePane.tsx new file mode 100644 index 0000000000..c7aa3a8dcf --- /dev/null +++ b/src/browser/components/ChatPane/HydrationStablePane.tsx @@ -0,0 +1,65 @@ +import React, { useLayoutEffect, useRef, useState } from "react"; + +interface HydrationStablePaneProps { + workspaceId: string; + isHydrating: boolean; + className?: string; + dataComponent?: string; + children: React.ReactNode; +} + +export const HydrationStablePane: React.FC = (props) => { + const paneRef = useRef(null); + const paneHeightByWorkspaceIdRef = useRef(new Map()); + const lastMeasuredPaneHeightRef = useRef(0); + const [reservedPaneHeightPx, setReservedPaneHeightPx] = useState(null); + + useLayoutEffect(() => { + const pane = paneRef.current; + if (!pane) { + return; + } + + const observer = new ResizeObserver((entries) => { + const nextHeight = Math.max( + 0, + Math.round(entries[0]?.contentRect.height ?? pane.getBoundingClientRect().height) + ); + lastMeasuredPaneHeightRef.current = nextHeight; + paneHeightByWorkspaceIdRef.current.set(props.workspaceId, nextHeight); + }); + + observer.observe(pane); + return () => { + observer.disconnect(); + }; + }, [props.workspaceId]); + + useLayoutEffect(() => { + if (!props.isHydrating) { + setReservedPaneHeightPx(null); + return; + } + + const cachedPaneHeight = paneHeightByWorkspaceIdRef.current.get(props.workspaceId); + const fallbackPaneHeight = lastMeasuredPaneHeightRef.current; + const reservedPaneHeight = cachedPaneHeight ?? fallbackPaneHeight; + + // Keep the whole composer region steady while workspace hydration catches up. The shell-level + // fix stops the input from disappearing entirely, but workspace-specific banners/review chips + // above the textarea can still collapse and re-expand during a switch, which looks like a + // vertical tear in Electron. Hold the most recent pane height until hydration finishes. + setReservedPaneHeightPx(reservedPaneHeight > 0 ? reservedPaneHeight : null); + }, [props.workspaceId, props.isHydrating]); + + return ( +
+ {props.children} +
+ ); +}; diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index be8777ce78..aa0c578acd 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -352,12 +352,76 @@ describe("MockAiStreamPlayer", () => { ); await new Promise((resolve) => setTimeout(resolve, 350)); + const replacementPartial = await historyService.readPartial(workspaceId); + expect(replacementPartial).not.toBeNull(); expect(errorEvents).toHaveLength(0); await player.stop(workspaceId); await waitForCondition(() => !player.isStreaming(workspaceId), 1000); }); + test("does not let stale stream-end cleanup delete a replacement stream partial", 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-end"; + const firstUserMessage = createMuxMessage( + "user-stream-end-first", + "user", + "[mock:list-languages] List 3 programming languages", + { + 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-end-second", + "user", + "[force] replacement stream after completed turn", + { + timestamp: Date.now(), + } + ); + + const replacementPlayResult = await player.play([replacementUserMessage], workspaceId); + expect(replacementPlayResult.success).toBe(true); + + await waitForCondition( + async () => (await historyService.readPartial(workspaceId)) !== null, + 1500 + ); + const replacementPartial = await historyService.readPartial(workspaceId); + expect(replacementPartial).not.toBeNull(); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + const partialAfterStaleCleanup = await historyService.readPartial(workspaceId); + expect(partialAfterStaleCleanup).not.toBeNull(); + expect(partialAfterStaleCleanup?.id).toBe(replacementPartial?.id); + + 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(); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 7a9be415f5..16cf849855 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -254,6 +254,10 @@ export class MockAiStreamPlayer { await this.stopActiveStream(workspaceId); } + private isCurrentActiveStream(workspaceId: string, active: ActiveStream): boolean { + return !active.cancelled && this.activeStreams.get(workspaceId) === active; + } + async play( messages: MuxMessage[], workspaceId: string, @@ -721,6 +725,10 @@ export class MockAiStreamPlayer { } case "stream-error": { const payload: MockStreamErrorEvent = event; + if (!this.isCurrentActiveStream(workspaceId, active)) { + return; + } + const deletePartialResult = await this.deps.historyService.deletePartial(workspaceId); if (!deletePartialResult.success) { log.error(`Failed to clear mock partial for ${messageId}: ${deletePartialResult.error}`); @@ -728,7 +736,7 @@ export class MockAiStreamPlayer { // 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) { + if (!this.isCurrentActiveStream(workspaceId, active)) { return; } @@ -788,12 +796,16 @@ export class MockAiStreamPlayer { } } } + if (!this.isCurrentActiveStream(workspaceId, active)) { + return; + } + 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; + if (!this.isCurrentActiveStream(workspaceId, active)) return; this.deps.aiService.emit("stream-end", payload); this.cleanup(workspaceId); From 56be47759db09c979f80c1734712eba2b50071a9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Apr 2026 11:52:27 -0500 Subject: [PATCH 17/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20clear=20stale=20opt?= =?UTF-8?q?imistic=20startup=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve optimistic startup through the first authoritative idle catch-up for new chats, but clear it on a later idle catch-up if no replayed turn ever appears so the workspace cannot stay stuck in starting mode forever. Add WorkspaceStore regression coverage for the second idle catch-up path. --- _Generated with `mux` • Model: `openai:gpt-5.4` • Thinking: `xhigh` • Cost: `772332{MUX_COSTS_USD}`_ --- src/browser/stores/WorkspaceStore.test.ts | 43 +++++++++++++++++++ src/browser/stores/WorkspaceStore.ts | 5 ++- .../messages/StreamingMessageAggregator.ts | 19 ++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/browser/stores/WorkspaceStore.test.ts b/src/browser/stores/WorkspaceStore.test.ts index 9c418aaa3f..f4120772de 100644 --- a/src/browser/stores/WorkspaceStore.test.ts +++ b/src/browser/stores/WorkspaceStore.test.ts @@ -1634,6 +1634,49 @@ describe("WorkspaceStore", () => { expect(clearedStarting).toBe(true); }); + it("clears optimistic starting state after a second authoritative idle catch-up", async () => { + const workspaceId = "optimistic-pending-start-idle-catch-up"; + const otherWorkspaceId = "optimistic-pending-start-idle-catch-up-other"; + const requestedModel = "openai:gpt-4o-mini"; + let subscriptionCount = 0; + + mockOnChat.mockImplementation(async function* ( + input?: { workspaceId: string; mode?: unknown }, + options?: { signal?: AbortSignal } + ): AsyncGenerator { + if (input?.workspaceId !== workspaceId) { + await waitForAbortSignal(options?.signal); + return; + } + + subscriptionCount += 1; + yield { + type: "caught-up", + replay: subscriptionCount === 1 ? "full" : "since", + }; + await waitForAbortSignal(options?.signal); + }); + + createAndAddWorkspace(store, workspaceId); + store.markPendingInitialSend(workspaceId, requestedModel); + + const keptStartingThroughFirstIdleCatchUp = await waitUntil(() => { + const state = store.getWorkspaceState(workspaceId); + return subscriptionCount >= 1 && state.isStreamStarting === true; + }); + expect(keptStartingThroughFirstIdleCatchUp).toBe(true); + + createAndAddWorkspace(store, otherWorkspaceId); + store.setActiveWorkspaceId(workspaceId); + + const clearedStartingAfterSecondIdleCatchUp = await waitUntil(() => { + const state = store.getWorkspaceState(workspaceId); + return subscriptionCount >= 2 && state.pendingStreamStartTime === null; + }); + expect(clearedStartingAfterSecondIdleCatchUp).toBe(true); + expect(store.getWorkspaceState(workspaceId).isStreamStarting).toBe(false); + }); + 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"; diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index c86652d091..bd2ad32bf6 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -3511,8 +3511,9 @@ export class WorkspaceStore { aggregator.clearActiveStreams(); } // 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. + // Preserve exactly one optimistic new-chat catch-up cycle: the first authoritative idle + // caught-up can arrive before the delayed first send is replayed, but if a later catch-up + // still reports no active stream then the optimistic barrier was stale and must clear. if (serverActiveStreamMessageId === undefined) { aggregator.clearPendingStreamStartIfNotOptimistic(); } diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index f5b4df0616..d02c04c648 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -458,6 +458,7 @@ export class StreamingMessageAggregator { // 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; + private optimisticPendingStreamStartIdleCaughtUpCount = 0; // Last completed stream timing stats (preserved after stream ends for display) // Unlike activeStreams, this persists until the next stream starts @@ -1258,6 +1259,7 @@ export class StreamingMessageAggregator { markOptimisticPendingStreamStart(model: string | null): void { this.optimisticPendingStreamStart = true; + this.optimisticPendingStreamStartIdleCaughtUpCount = 0; this.pendingCompactionRequest = null; this.pendingStreamModel = model; this.setPendingStreamStartTime(Date.now()); @@ -1266,7 +1268,18 @@ export class StreamingMessageAggregator { clearPendingStreamStartIfNotOptimistic(): void { if (!this.optimisticPendingStreamStart) { this.clearPendingStreamStart(); + return; + } + + // Preserve exactly one authoritative idle caught-up cycle for a just-created workspace. + // If the server later still reports no active stream and no replayed turn has arrived, + // the optimistic startup barrier is stale and should clear so recovery UI can reappear. + if (this.optimisticPendingStreamStartIdleCaughtUpCount > 0) { + this.clearPendingStreamStart(); + return; } + + this.optimisticPendingStreamStartIdleCaughtUpCount += 1; } private getLatestHistoricalCompactionRequest(): PendingCompactionRequest | null { @@ -1326,6 +1339,7 @@ export class StreamingMessageAggregator { this.pendingCompactionRequest = null; this.pendingStreamModel = null; this.optimisticPendingStreamStart = false; + this.optimisticPendingStreamStartIdleCaughtUpCount = 0; } } @@ -1646,6 +1660,8 @@ export class StreamingMessageAggregator { pendingCompactionRequest: this.pendingCompactionRequest, pendingStreamModel: this.pendingStreamModel, optimisticPendingStreamStart: this.optimisticPendingStreamStart, + optimisticPendingStreamStartIdleCaughtUpCount: + this.optimisticPendingStreamStartIdleCaughtUpCount, }; this.clear(); @@ -1658,6 +1674,8 @@ export class StreamingMessageAggregator { this.pendingCompactionRequest = pendingStreamSnapshot.pendingCompactionRequest; this.pendingStreamModel = pendingStreamSnapshot.pendingStreamModel; this.optimisticPendingStreamStart = pendingStreamSnapshot.optimisticPendingStreamStart; + this.optimisticPendingStreamStartIdleCaughtUpCount = + pendingStreamSnapshot.optimisticPendingStreamStartIdleCaughtUpCount; } clear(): void { @@ -2518,6 +2536,7 @@ export class StreamingMessageAggregator { : null; this.optimisticPendingStreamStart = false; + this.optimisticPendingStreamStartIdleCaughtUpCount = 0; this.pendingStreamModel = muxMetadata?.requestedModel ?? null; if (muxMeta?.displayStatus) { From 1011781465605e05e8338437c46cf190bd1ac7fb Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Apr 2026 12:23:43 -0500 Subject: [PATCH 18/20] Fix stale mock partial writes after stop --- .../services/mock/mockAiStreamPlayer.test.ts | 50 +++++++++++++++++++ src/node/services/mock/mockAiStreamPlayer.ts | 20 ++++++++ 2 files changed, 70 insertions(+) diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index aa0c578acd..6630c37713 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -228,6 +228,56 @@ describe("MockAiStreamPlayer", () => { ); }); + test("cleans up a delayed partial write after stop cancels the stream", async () => { + const aiServiceStub = new EventEmitter(); + + const player = new MockAiStreamPlayer({ + historyService, + aiService: aiServiceStub as unknown as AIService, + }); + + const originalWritePartial = historyService.writePartial.bind(historyService); + let releaseFirstWrite!: () => void; + const firstWriteGate = new Promise((resolve) => { + releaseFirstWrite = () => resolve(); + }); + let writePartialCallCount = 0; + spyOn(historyService, "writePartial").mockImplementation( + async (workspaceIdToWrite, message) => { + writePartialCallCount += 1; + if (writePartialCallCount === 1) { + await firstWriteGate; + } + return await originalWritePartial(workspaceIdToWrite, message); + } + ); + + const workspaceId = "workspace-stale-partial-after-stop"; + const userMessage = createMuxMessage("user-stale-partial", "user", "[force] keep streaming", { + timestamp: Date.now(), + }); + + try { + const playResult = await player.play([userMessage], workspaceId); + expect(playResult.success).toBe(true); + + await waitForCondition(() => writePartialCallCount >= 1, 1000); + + await player.stop(workspaceId); + expect(player.isStreaming(workspaceId)).toBe(false); + expect(await historyService.readPartial(workspaceId)).toBeNull(); + + releaseFirstWrite(); + await waitForCondition( + async () => (await historyService.readPartial(workspaceId)) === null, + 1000 + ); + } finally { + releaseFirstWrite(); + await player.stop(workspaceId); + } + }); + test("waits for partial cleanup before a replacement stream starts writing its own partial", async () => { const aiServiceStub = new EventEmitter(); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 16cf849855..d651c79899 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -550,6 +550,7 @@ export class MockAiStreamPlayer { return; } await this.writePartialFromActiveStream(workspaceId, current); + await this.clearStalePartialAfterWrite(workspaceId, current); }); }, MOCK_PARTIAL_WRITE_THROTTLE_MS); } @@ -584,6 +585,25 @@ export class MockAiStreamPlayer { } } + private async clearStalePartialAfterWrite( + workspaceId: string, + active: ActiveStream + ): Promise { + if (this.isCurrentActiveStream(workspaceId, active)) { + return; + } + + // stopActiveStream()/replacement can cancel the stream after the pre-write ownership check + // but before the async partial write finishes. Re-check here so a stale write cannot + // recreate partial.json after an interrupt or replacement already cleared it. + const deletePartialResult = await this.deps.historyService.deletePartial(workspaceId); + if (!deletePartialResult.success) { + log.error( + `Failed to clear stale mock partial after write for ${active.messageId}: ${deletePartialResult.error}` + ); + } + } + private buildCompletedParts( active: ActiveStream, completedParts: StreamEndEvent["parts"] From 883fc936f331beb452cab15da9df10eb6e80f7b5 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Apr 2026 12:48:06 -0500 Subject: [PATCH 19/20] Handle mock aborts during replacement cleanup --- .../services/mock/mockAiStreamPlayer.test.ts | 83 +++++++++++++++++++ src/node/services/mock/mockAiStreamPlayer.ts | 21 +++-- 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index 6630c37713..ee2e7987a0 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -179,6 +179,89 @@ describe("MockAiStreamPlayer", () => { expect(storedMessages.some((msg) => msg.id === assistantMsg.id)).toBe(false); }); + test("does not schedule a replacement stream when abort fires during prior stop cleanup", 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; + let releaseStopCleanup!: () => void; + const stopCleanupGate = new Promise((resolve) => { + releaseStopCleanup = () => resolve(); + }); + spyOn(historyService, "deletePartial").mockImplementation(async (workspaceIdToDelete) => { + deletePartialCallCount += 1; + if (deletePartialCallCount === 1) { + await stopCleanupGate; + } + return await originalDeletePartial(workspaceIdToDelete); + }); + + const workspaceId = "workspace-abort-during-replacement-stop"; + const streamStartMessageIds: string[] = []; + aiServiceStub.on("stream-start", (payload: unknown) => { + if (readWorkspaceId(payload) !== workspaceId) { + return; + } + const messageId = (payload as { messageId?: string }).messageId; + if (typeof messageId === "string") { + streamStartMessageIds.push(messageId); + } + }); + + const firstUserMessage = createMuxMessage( + "user-abort-replacement-first", + "user", + "[force] first stream before aborted replacement", + { + timestamp: Date.now(), + } + ); + + try { + const firstPlayResult = await player.play([firstUserMessage], workspaceId); + expect(firstPlayResult.success).toBe(true); + expect(streamStartMessageIds).toHaveLength(1); + + const abortController = new AbortController(); + const replacementUserMessage = createMuxMessage( + "user-abort-replacement-second", + "user", + "[force] replacement stream should abort before scheduling", + { + timestamp: Date.now(), + } + ); + + const replacementPlayPromise = player.play([replacementUserMessage], workspaceId, { + abortSignal: abortController.signal, + }); + + await waitForCondition(() => deletePartialCallCount >= 1, 1000); + abortController.abort(); + releaseStopCleanup(); + + const replacementPlayResult = await replacementPlayPromise; + expect(replacementPlayResult.success).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(player.isStreaming(workspaceId)).toBe(false); + expect(streamStartMessageIds).toHaveLength(1); + + const historyResult = await historyService.getLastMessages(workspaceId, 10); + const historyMessages = historyResult.success ? historyResult.data : []; + expect(historyMessages.filter((message) => message.role === "assistant")).toHaveLength(1); + } finally { + releaseStopCleanup(); + await player.stop(workspaceId); + } + }); + test("writes partial assistant state while a mock stream is still in progress", async () => { const aiServiceStub = new EventEmitter(); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index d651c79899..10b362aeb5 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -254,6 +254,15 @@ export class MockAiStreamPlayer { await this.stopActiveStream(workspaceId); } + private async deleteAssistantPlaceholder(workspaceId: string, messageId: string): Promise { + const deleteResult = await this.deps.historyService.deleteMessage(workspaceId, messageId); + if (!deleteResult.success) { + log.error( + `Failed to delete aborted mock assistant placeholder (${messageId}): ${deleteResult.error}` + ); + } + } + private isCurrentActiveStream(workspaceId: string, active: ActiveStream): boolean { return !active.cancelled && this.activeStreams.get(workspaceId) === active; } @@ -365,12 +374,7 @@ export class MockAiStreamPlayer { } if (abortSignal?.aborted) { - const deleteResult = await this.deps.historyService.deleteMessage(workspaceId, messageId); - if (!deleteResult.success) { - log.error( - `Failed to delete aborted mock assistant placeholder (${messageId}): ${deleteResult.error}` - ); - } + await this.deleteAssistantPlaceholder(workspaceId, messageId); return Ok(undefined); } @@ -382,6 +386,11 @@ export class MockAiStreamPlayer { await this.stopActiveStream(workspaceId); } + if (abortSignal?.aborted) { + await this.deleteAssistantPlaceholder(workspaceId, messageId); + return Ok(undefined); + } + this.scheduleEvents(workspaceId, events, messageId, historySequence); await streamStartPromise; From 8f454f9b2edecb21b4d30e7bbaddbccae4754a2a Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 14 Apr 2026 13:01:10 -0500 Subject: [PATCH 20/20] Guard stale mock partial cleanup by message id --- src/node/services/historyService.ts | 29 +++++ .../services/mock/mockAiStreamPlayer.test.ts | 106 ++++++++++++++++++ src/node/services/mock/mockAiStreamPlayer.ts | 9 +- 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index c50db80015..a9aaff9b7c 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -885,6 +885,35 @@ export class HistoryService { }); } + /** + * Delete the partial message file only when it still belongs to the expected message. + * Returns true when a matching partial was deleted, false when the partial was missing + * or belonged to a different message. + */ + async deletePartialIfMessageIdMatches( + workspaceId: string, + messageId: string + ): Promise> { + return this.fileLocks.withLock(workspaceId, async () => { + try { + const partialPath = this.getPartialPath(workspaceId); + const data = await fs.readFile(partialPath, "utf-8"); + const partialMessage = normalizeLegacyMuxMetadata(JSON.parse(data) as MuxMessage); + if (partialMessage.id !== messageId) { + return Ok(false); + } + await fs.unlink(partialPath); + return Ok(true); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return Ok(false); + } + const errorMessage = getErrorMessage(error); + return Err(`Failed to delete matching partial: ${errorMessage}`); + } + }); + } + /** * Commit any existing partial message to chat history and delete partial.json. * diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index ee2e7987a0..179351a720 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -361,6 +361,112 @@ describe("MockAiStreamPlayer", () => { } }); + test("does not let stale delayed-write cleanup delete a replacement stream partial", async () => { + const aiServiceStub = new EventEmitter(); + + const player = new MockAiStreamPlayer({ + historyService, + aiService: aiServiceStub as unknown as AIService, + }); + + const originalWritePartial = historyService.writePartial.bind(historyService); + let releaseFirstWrite!: () => void; + const firstWriteGate = new Promise((resolve) => { + releaseFirstWrite = () => resolve(); + }); + let writePartialCallCount = 0; + spyOn(historyService, "writePartial").mockImplementation( + async (workspaceIdToWrite, message) => { + writePartialCallCount += 1; + if (writePartialCallCount === 1) { + await firstWriteGate; + } + return await originalWritePartial(workspaceIdToWrite, message); + } + ); + + const originalDeletePartialIfMessageIdMatches = + historyService.deletePartialIfMessageIdMatches.bind(historyService); + let releaseStaleCleanup!: () => void; + const staleCleanupGate = new Promise((resolve) => { + releaseStaleCleanup = () => resolve(); + }); + let deleteMatchingCallCount = 0; + spyOn(historyService, "deletePartialIfMessageIdMatches").mockImplementation( + async (workspaceIdToDelete, messageIdToDelete) => { + deleteMatchingCallCount += 1; + if (deleteMatchingCallCount === 1) { + await staleCleanupGate; + } + return await originalDeletePartialIfMessageIdMatches( + workspaceIdToDelete, + messageIdToDelete + ); + } + ); + + const workspaceId = "workspace-stale-delayed-write-cleanup"; + const streamStartMessageIds: string[] = []; + aiServiceStub.on("stream-start", (payload: unknown) => { + if (readWorkspaceId(payload) !== workspaceId) { + return; + } + const messageId = (payload as { messageId?: string }).messageId; + if (typeof messageId === "string") { + streamStartMessageIds.push(messageId); + } + }); + + const firstUserMessage = createMuxMessage( + "user-stale-delayed-write-first", + "user", + "[force] first stream before stale delayed-write cleanup", + { + timestamp: Date.now(), + } + ); + + try { + const firstPlayResult = await player.play([firstUserMessage], workspaceId); + expect(firstPlayResult.success).toBe(true); + + await waitForCondition(() => writePartialCallCount >= 1, 1000); + + const replacementUserMessage = createMuxMessage( + "user-stale-delayed-write-second", + "user", + "[force] replacement stream should keep its partial after stale cleanup", + { + timestamp: Date.now(), + } + ); + + const replacementPlayResult = await player.play([replacementUserMessage], workspaceId); + expect(replacementPlayResult.success).toBe(true); + + await waitForCondition(() => streamStartMessageIds.length >= 2, 1000); + const replacementMessageId = streamStartMessageIds[1]; + + releaseFirstWrite(); + await waitForCondition(() => deleteMatchingCallCount >= 1, 1000); + + await waitForCondition( + async () => (await historyService.readPartial(workspaceId))?.id === replacementMessageId, + 2000 + ); + + releaseStaleCleanup(); + await waitForCondition( + async () => (await historyService.readPartial(workspaceId))?.id === replacementMessageId, + 1000 + ); + } finally { + releaseFirstWrite(); + releaseStaleCleanup(); + await player.stop(workspaceId); + } + }); + test("waits for partial cleanup before a replacement stream starts writing its own partial", async () => { const aiServiceStub = new EventEmitter(); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 10b362aeb5..b9ad2e03f0 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -603,9 +603,12 @@ export class MockAiStreamPlayer { } // stopActiveStream()/replacement can cancel the stream after the pre-write ownership check - // but before the async partial write finishes. Re-check here so a stale write cannot - // recreate partial.json after an interrupt or replacement already cleared it. - const deletePartialResult = await this.deps.historyService.deletePartial(workspaceId); + // but before the async partial write finishes. Re-check here and delete only if the stale + // stream still owns partial.json so a replacement stream's newer snapshot survives. + const deletePartialResult = await this.deps.historyService.deletePartialIfMessageIdMatches( + workspaceId, + active.messageId + ); if (!deletePartialResult.success) { log.error( `Failed to clear stale mock partial after write for ${active.messageId}: ${deletePartialResult.error}`