From 4d0d490df3279ca8c7b05e3fc3ab392b065bd4fb Mon Sep 17 00:00:00 2001 From: extoci Date: Fri, 9 Oct 2026 13:06:24 +0000 Subject: [PATCH] fix(web): clear stale messages when switching threads --- .../web/src/components/ChatView.logic.test.ts | 57 +++----- apps/web/src/components/ChatView.logic.ts | 72 ++-------- apps/web/src/components/ChatView.tsx | 128 ++++++------------ 3 files changed, 69 insertions(+), 188 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index f870182b1b2d..b4fbab2328d2 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, + resetRememberedThreadTimelines, resolveThreadSwitchTimeline, - threadKeysShareEnvironment, timelineHasEphemeralPreviewUrls, scheduleEnvironmentReconnectWarning, codexArtifactTemplatePromptToAppend, @@ -1027,20 +1024,20 @@ describe("draft hero submission transition", () => { describe("resolveThreadSwitchTimeline", () => { afterEach(() => { - resetHeldThreadTimeline(); + resetRememberedThreadTimelines(); }); const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] }; - it("keeps the previous thread's entries while the next thread is loading", () => { + it("clears the previous thread's entries while an uncached destination is loading", () => { + 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,27 @@ 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", - }); - expect(peekHeldThreadTimeline()).toEqual({ - ...held, - markdownCwd: "/repo/a", - workspaceRoot: "/repo/a", - }); - }); - - it("survives a ChatView remount by remembering the last ready timeline", () => { + it("only restores the selected thread during rapid switches", () => { rememberReadyThreadTimeline(held); - expect(peekHeldThreadTimeline()).toEqual(held); + rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1"] }); expect( resolveThreadSwitchTimeline({ loading: true, - activeThreadKey: "env-1:thread-b", + activeThreadKey: "env-1:thread-c", nextEntries: [], }), - ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + ).toEqual({ entries: [], displayThreadKey: "env-1:thread-c" }); + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-a", + nextEntries: [], + }), + ).toEqual({ entries: held.entries, displayThreadKey: held.threadKey }); }); it("paints a remembered destination instead of the last-viewed thread", () => { @@ -1125,21 +1114,15 @@ describe("resolveThreadSwitchTimeline", () => { ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); }); - it("does not hold another environment's timeline across a jump", () => { - expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false); + it("does not reuse a matching thread id from another environment", () => { + rememberReadyThreadTimeline(held); expect( resolveThreadSwitchTimeline({ loading: true, - activeThreadKey: "env-2:thread-b", + activeThreadKey: "env-2:thread-a", 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); + ).toEqual({ entries: [], displayThreadKey: "env-2:thread-a" }); }); it("does not remember a timeline that still has handoff blob previews", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1e79964caa0a..1f2a0c77943c 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,30 +310,21 @@ 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 timelines across ChatView remounts so a cached destination can + * paint immediately. Loading threads may only show their own cached entries; + * the rest of the UI already refers to the selected thread. */ -export type HeldThreadTimeline = { +type RememberedThreadTimeline = { threadKey: string | null; entries: T; - markdownCwd?: string | null; - workspaceRoot?: string | null; }; const MAX_REMEMBERED_THREAD_TIMELINES = 16; -let rememberedThreadTimelines = new Map>(); +let rememberedThreadTimelines = new Map>(); let rememberedThreadTimelineOrder: string[] = []; -let lastReadyThreadKey: string | null = null; -function rememberThreadTimelineEntries(held: HeldThreadTimeline): void { +function rememberThreadTimelineEntries(held: RememberedThreadTimeline): void { if (held.threadKey === null) { return; } @@ -349,11 +339,10 @@ function rememberThreadTimelineEntries(held: HeldThreadTimeline( - held: HeldThreadTimeline, + held: RememberedThreadTimeline, ): void { if (held.threadKey === null || held.entries.length === 0) { return; @@ -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 { +export function resetRememberedThreadTimelines(): 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,6 @@ 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 }; - } return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b760810a3876..0594d047a15f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -13,8 +13,6 @@ import { restorePlanFollowUpComposer } from "./ChatView.logic"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./chat/queuedMessageEdit"; import { - isPaintOnlyThreadTimeline, - peekHeldThreadTimeline, peekRememberedThreadTimeline, rememberReadyThreadTimeline, resolveThreadSwitchTimeline, @@ -1551,10 +1549,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 @@ -3974,11 +3968,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; @@ -4241,13 +4230,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 @@ -11028,9 +11012,9 @@ export default function ChatView(props: ChatViewProps) { ); const onTimelineRollbackCheckpoint = useCallback( (input: { readonly checkpointId: string; readonly scopeId: string }) => { - if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input); + void onRollbackCheckpoint(input); }, - [onRollbackCheckpoint, paintOnlyDisplayedTimeline], + [onRollbackCheckpoint], ); // The revert handler is read from a ref at call-time so the callback // reference is fully stable and never busts TimelineRowCtx identity. @@ -11445,8 +11429,7 @@ export default function ChatView(props: ChatViewProps) { skills: timelineSkills, progressive: serverConfig?.threadFindProgressive === true, thread: activeThreadRef, - enabled: - isServerThread && serverConfig?.threadFind === true && !paintOnlyDisplayedTimeline, + enabled: isServerThread && serverConfig?.threadFind === true, content: serverProjection ?? undefined, }} controlsRef={threadFindControlsRef} @@ -11499,94 +11482,65 @@ export default function ChatView(props: ChatViewProps) {
{/* Messages — LegendList handles virtualization and scrolling internally */} {} : onForkFromRun} + parentThreadLink={parentThreadLink} + onForkFromRun={onForkFromRun} onRollbackCheckpoint={onTimelineRollbackCheckpoint} - 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={timelineSkills} - anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId} + anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerTimelineInset} - liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} + liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} @@ -11594,7 +11548,7 @@ export default function ChatView(props: ChatViewProps) { cancelPositionRestoreRef={cancelPositionRestoreRef} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} - {...(paintOnlyDisplayedTimeline || threadHistoryControls === undefined + {...(threadHistoryControls === undefined ? {} : { historyControls: threadHistoryControls })} />