From 292479e3ed4340b3f4cee813b3d77c27e2425329 Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Thu, 8 Oct 2026 11:38:51 +0300 Subject: [PATCH] fix(web): stop showing old messages during thread switches --- .../web/src/components/ChatView.logic.test.ts | 78 ++++++----- apps/web/src/components/ChatView.logic.ts | 64 +-------- apps/web/src/components/ChatView.tsx | 122 ++++++------------ 3 files changed, 93 insertions(+), 171 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bf8d32f1daf3..84f16c10d75b 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -45,13 +45,10 @@ import { resolveProactiveTurnDiffAction, resolveDraftHeroState, resolveWorktreeSetupProgress, - isPaintOnlyThreadTimeline, - peekHeldThreadTimeline, peekRememberedThreadTimeline, rememberReadyThreadTimeline, resetHeldThreadTimeline, resolveThreadSwitchTimeline, - threadKeysShareEnvironment, timelineHasEphemeralPreviewUrls, scheduleEnvironmentReconnectWarning, codexArtifactTemplatePromptToAppend, @@ -1032,15 +1029,15 @@ describe("resolveThreadSwitchTimeline", () => { const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] }; - it("keeps the previous thread's entries while the next thread is loading", () => { + it("does not paint the previous transcript under a loading destination's header and layout", () => { + rememberReadyThreadTimeline(held); expect( resolveThreadSwitchTimeline({ loading: true, activeThreadKey: "env-1:thread-b", nextEntries: [], - lastReady: held, }), - ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + ).toEqual({ entries: [], displayThreadKey: "env-1:thread-b" }); }); it("shows the new thread once its detail is ready", () => { @@ -1049,7 +1046,6 @@ describe("resolveThreadSwitchTimeline", () => { loading: false, activeThreadKey: "env-1:thread-b", nextEntries: ["b1"], - lastReady: held, }), ).toEqual({ entries: ["b1"], displayThreadKey: "env-1:thread-b" }); }); @@ -1060,34 +1056,64 @@ describe("resolveThreadSwitchTimeline", () => { loading: true, activeThreadKey: "env-1:thread-a", nextEntries: [], - lastReady: null, }), ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); }); - it("keeps the held thread workspace cwd with the snapshot", () => { - rememberReadyThreadTimeline({ - ...held, - markdownCwd: "/repo/a", - workspaceRoot: "/repo/a", + it("does not borrow a different thread's cached history after a ChatView remount", () => { + rememberReadyThreadTimeline(held); + expect(peekRememberedThreadTimeline(held.threadKey)).toEqual(held.entries); + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-b", + nextEntries: [], + }), + ).toEqual({ entries: [], displayThreadKey: "env-1:thread-b" }); + }); + + it("keeps cold-load and ready frames scoped to the destination, then restores a cached return", () => { + rememberReadyThreadTimeline(held); + const loading = resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-b", + nextEntries: [], }); - expect(peekHeldThreadTimeline()).toEqual({ - ...held, - markdownCwd: "/repo/a", - workspaceRoot: "/repo/a", + const ready = resolveThreadSwitchTimeline({ + loading: false, + activeThreadKey: "env-1:thread-b", + nextEntries: ["b1"], }); + rememberReadyThreadTimeline({ threadKey: ready.displayThreadKey, entries: ready.entries }); + const returning = resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-a", + nextEntries: [], + }); + + expect([loading, ready, returning]).toEqual([ + { entries: [], displayThreadKey: "env-1:thread-b" }, + { entries: ["b1"], displayThreadKey: "env-1:thread-b" }, + { entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }, + ]); }); - it("survives a ChatView remount by remembering the last ready timeline", () => { + it("does not substitute the last-viewed thread when the destination has left the cache", () => { rememberReadyThreadTimeline(held); - expect(peekHeldThreadTimeline()).toEqual(held); + for (let index = 0; index < 20; index += 1) { + rememberReadyThreadTimeline({ + threadKey: `env-1:other-${index}`, + entries: [`other-message-${index}`], + }); + } + expect(peekRememberedThreadTimeline("env-1:thread-a")).toBeNull(); expect( - resolveThreadSwitchTimeline({ + resolveThreadSwitchTimeline({ loading: true, - activeThreadKey: "env-1:thread-b", + activeThreadKey: "env-1:thread-a", nextEntries: [], }), - ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); }); it("paints a remembered destination instead of the last-viewed thread", () => { @@ -1126,22 +1152,16 @@ describe("resolveThreadSwitchTimeline", () => { }); it("does not hold another environment's timeline across a jump", () => { - expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false); + rememberReadyThreadTimeline(held); expect( resolveThreadSwitchTimeline({ loading: true, activeThreadKey: "env-2:thread-b", nextEntries: [], - lastReady: held, }), ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" }); }); - it("treats a foreign held timeline as paint-only", () => { - expect(isPaintOnlyThreadTimeline("env-1:thread-a", "env-1:thread-b")).toBe(true); - expect(isPaintOnlyThreadTimeline("env-1:thread-b", "env-1:thread-b")).toBe(false); - }); - it("does not remember a timeline that still has handoff blob previews", () => { expect( timelineHasEphemeralPreviewUrls([ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index f36e54d334fe..8e4ab41eb2b5 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -27,7 +27,6 @@ import { } from "@t3tools/contracts"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import * as DateTime from "effect/DateTime"; -import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure, @@ -311,28 +310,19 @@ export function resolveDraftHeroState(input: { } /** - * Keep painted timelines on screen across thread jumps. Remounting LegendList - * (or handing it an empty first paint) punches a hole through the chat pane — - * white in light mode — so cmd+1/2/3 spam flashes even when the destination - * is already cached. - * - * Stored at module scope because ChatView remounts when the thread route - * changes (same pattern as the thread-error banner session dismissals). - * Remember more than the last thread so jumping back to cmd+1 does not show - * cmd+3's messages, and so a cached destination can paint on the first frame. + * Remember each thread's ready timeline so a cached destination can paint + * immediately, including after a ChatView remount. An uncached destination + * uses the loading surface until its own history arrives. */ export type HeldThreadTimeline = { threadKey: string | null; entries: T; - markdownCwd?: string | null; - workspaceRoot?: string | null; }; const MAX_REMEMBERED_THREAD_TIMELINES = 16; let rememberedThreadTimelines = new Map>(); let rememberedThreadTimelineOrder: string[] = []; -let lastReadyThreadKey: string | null = null; function rememberThreadTimelineEntries(held: HeldThreadTimeline): void { if (held.threadKey === null) { @@ -349,7 +339,6 @@ function rememberThreadTimelineEntries(held: HeldThreadTimeline( @@ -370,42 +359,9 @@ export function peekRememberedThreadTimeline( return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null; } -export function peekHeldThreadTimeline< - T extends readonly unknown[], ->(): HeldThreadTimeline | null { - if (lastReadyThreadKey === null) { - return null; - } - const held = rememberedThreadTimelines.get(lastReadyThreadKey); - if (held === undefined || held.entries.length === 0) { - return null; - } - return held as HeldThreadTimeline; -} - export function resetHeldThreadTimeline(): void { rememberedThreadTimelines = new Map(); rememberedThreadTimelineOrder = []; - lastReadyThreadKey = null; -} - -export function threadKeysShareEnvironment(left: string | null, right: string | null): boolean { - if (left === null || right === null) { - return false; - } - const leftRef = parseScopedThreadKey(left); - const rightRef = parseScopedThreadKey(right); - return leftRef !== null && rightRef !== null && leftRef.environmentId === rightRef.environmentId; -} - -/** True while we still paint another thread's last snapshot. */ -export function isPaintOnlyThreadTimeline( - displayThreadKey: string | null, - activeThreadKey: string | null, -): boolean { - return ( - displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey - ); } export function resolveThreadSwitchTimeline(input: { @@ -413,7 +369,6 @@ export function resolveThreadSwitchTimeline(input: activeThreadKey: string | null; nextEntries: T; rememberedForActive?: T | null; - lastReady?: HeldThreadTimeline | null; }): { entries: T; displayThreadKey: string | null } { if (input.nextEntries.length > 0) { return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; @@ -425,17 +380,8 @@ export function resolveThreadSwitchTimeline(input: return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey }; } - const lastReady = input.lastReady ?? peekHeldThreadTimeline(); - if ( - input.loading && - lastReady !== null && - lastReady.threadKey !== null && - lastReady.threadKey !== input.activeThreadKey && - lastReady.entries.length > 0 && - threadKeysShareEnvironment(lastReady.threadKey, input.activeThreadKey) - ) { - return { entries: lastReady.entries, displayThreadKey: lastReady.threadKey }; - } + // The header and pane widths already belong to the destination. Painting + // another thread here flashes its text and reflows it before replacing it. return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6a021ef7e5e2..4a21ae31cd09 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -12,8 +12,6 @@ import { restorePlanFollowUpComposer } from "./ChatView.logic"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./chat/queuedMessageEdit"; import { - isPaintOnlyThreadTimeline, - peekHeldThreadTimeline, peekRememberedThreadTimeline, rememberReadyThreadTimeline, resolveThreadSwitchTimeline, @@ -1544,10 +1542,6 @@ function chatActionErrorMessage(error: unknown): string { } const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; -const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; -const noopHeldTurnDiff = (_turnId: RunId, _filePath?: string) => {}; -const noopHeldRevert = (_targetTurnCount: number) => {}; -const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; /** * Drops the send-time anchored end space. That space is what holds a sent @@ -3950,11 +3944,6 @@ export default function ChatView(props: ChatViewProps) { rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), }); const displayedTimelineKey = displayedTimeline.displayThreadKey ?? routeThreadKey; - const paintOnlyDisplayedTimeline = isPaintOnlyThreadTimeline( - displayedTimeline.displayThreadKey, - activeThreadKey, - ); - const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); const worktreeSetupOwnerKey = draftId ?? routeThreadKey; const worktreeSetupActive = worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey; @@ -4215,13 +4204,8 @@ export default function ChatView(props: ChatViewProps) { rememberReadyThreadTimeline({ threadKey: activeThreadKey, entries: timelineEntries, - markdownCwd: gitCwd, - workspaceRoot: activeWorkspaceRoot ?? null, }); - }, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]); - const heldPaintContext = paintOnlyDisplayedTimeline - ? peekHeldThreadTimeline() - : null; + }, [activeThreadKey, threadDetailLoading, timelineEntries]); const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Git status arrives after the composer paints. A checkout seen earlier in @@ -11276,99 +11260,71 @@ export default function ChatView(props: ChatViewProps) {
{/* Messages — LegendList handles virtualization and scrolling internally */} {} : onForkFromRun} + parentThreadLink={parentThreadLink} + onForkFromRun={onForkFromRun} onRollbackCheckpoint={(input) => { - if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input); + void onRollbackCheckpoint(input); }} - supportsConversationRollback={ - !paintOnlyDisplayedTimeline && supportsConversationRollback - } - onRevertToTurnCount={ - paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn - } - {...(!paintOnlyDisplayedTimeline - ? { onUseArtifactTemplate: useArtifactTemplate, onSendAppMessage: sendAppMessage } - : {})} + supportsConversationRollback={supportsConversationRollback} + onRevertToTurnCount={onRevertTimelineTurn} + onUseArtifactTemplate={useArtifactTemplate} + onSendAppMessage={sendAppMessage} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} - onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment} - onFileDownload={ - paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachment - } - markdownCwd={ - paintOnlyDisplayedTimeline - ? (heldPaintContext?.markdownCwd ?? undefined) - : (gitCwd ?? undefined) - } + onFileOpen={openFileAttachment} + onFileDownload={downloadFileAttachment} + markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} - workspaceRoot={ - paintOnlyDisplayedTimeline - ? (heldPaintContext?.workspaceRoot ?? undefined) - : activeWorkspaceRoot - } + workspaceRoot={activeWorkspaceRoot} skills={ activeProviderStatus ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) : EMPTY_PROVIDER_SKILLS } - anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId} + anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerTimelineInset} - liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} + liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} @@ -11376,7 +11332,7 @@ export default function ChatView(props: ChatViewProps) { cancelPositionRestoreRef={cancelPositionRestoreRef} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} - {...(paintOnlyDisplayedTimeline || threadHistoryControls === undefined + {...(threadHistoryControls === undefined ? {} : { historyControls: threadHistoryControls })} />