authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-10 23:50:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 23:50:33-07:00
log211618fd9fe39d3dde01171a6856ce9f633571c9
tree7017dc6d198b10b494e1a5804f5968da725722a5
parent8078c532ceeee5cb951325031f216171507f3d5e
signature Signed by PGP key B5690EEEBB952194

perf(web): huge-thread switch no longer blanks the chat pane (#11169)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com>

4 files changed, 472 insertions(+), 44 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+181
......@@ -59,6 +59,14 @@ import {
5959 resolveSendEnvMode,
6060 threadShellHasStarted,
6161 resolveDraftHeroState,
62 isPaintOnlyThreadTimeline,
63 peekHeldThreadTimeline,
64 peekRememberedThreadTimeline,
65 rememberReadyThreadTimeline,
66 resetHeldThreadTimeline,
67 resolveThreadSwitchTimeline,
68 threadKeysShareEnvironment,
69 timelineHasEphemeralPreviewUrls,
6270 scheduleEnvironmentReconnectWarning,
6371 startNewThreadForProject,
6472 codexArtifactTemplatePromptToAppend,
......@@ -565,6 +573,179 @@ describe("draft hero submission transition", () => {
565573 });
566574});
567575
576describe("resolveThreadSwitchTimeline", () => {
577 afterEach(() => {
578 resetHeldThreadTimeline();
579 });
580
581 const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] };
582
583 it("keeps the previous thread's entries while the next thread is loading", () => {
584 expect(
585 resolveThreadSwitchTimeline({
586 loading: true,
587 activeThreadKey: "env-1:thread-b",
588 nextEntries: [],
589 lastReady: held,
590 }),
591 ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
592 });
593
594 it("shows the new thread once its detail is ready", () => {
595 expect(
596 resolveThreadSwitchTimeline({
597 loading: false,
598 activeThreadKey: "env-1:thread-b",
599 nextEntries: ["b1"],
600 lastReady: held,
601 }),
602 ).toEqual({ entries: ["b1"], displayThreadKey: "env-1:thread-b" });
603 });
604
605 it("does not invent a timeline on the first open of a thread", () => {
606 expect(
607 resolveThreadSwitchTimeline({
608 loading: true,
609 activeThreadKey: "env-1:thread-a",
610 nextEntries: [],
611 lastReady: null,
612 }),
613 ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" });
614 });
615
616 it("keeps the held thread workspace cwd with the snapshot", () => {
617 rememberReadyThreadTimeline({
618 ...held,
619 markdownCwd: "/repo/a",
620 workspaceRoot: "/repo/a",
621 });
622 expect(peekHeldThreadTimeline<string[]>()).toEqual({
623 ...held,
624 markdownCwd: "/repo/a",
625 workspaceRoot: "/repo/a",
626 });
627 });
628
629 it("survives a ChatView remount by remembering the last ready timeline", () => {
630 rememberReadyThreadTimeline(held);
631 expect(peekHeldThreadTimeline<string[]>()).toEqual(held);
632 expect(
633 resolveThreadSwitchTimeline({
634 loading: true,
635 activeThreadKey: "env-1:thread-b",
636 nextEntries: [],
637 }),
638 ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
639 });
640
641 it("paints a remembered destination instead of the last-viewed thread", () => {
642 rememberReadyThreadTimeline(held);
643 rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1", "b2"] });
644 expect(peekRememberedThreadTimeline<string[]>("env-1:thread-a")).toEqual(["a1", "a2"]);
645 expect(
646 resolveThreadSwitchTimeline({
647 loading: true,
648 activeThreadKey: "env-1:thread-a",
649 nextEntries: [],
650 }),
651 ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
652 });
653
654 it("prefers live entries over a remembered snapshot", () => {
655 rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["stale-b"] });
656 expect(
657 resolveThreadSwitchTimeline({
658 loading: false,
659 activeThreadKey: "env-1:thread-b",
660 nextEntries: ["fresh-b"],
661 }),
662 ).toEqual({ entries: ["fresh-b"], displayThreadKey: "env-1:thread-b" });
663 });
664
665 it("does not keep a remembered snapshot on a resolved empty thread", () => {
666 rememberReadyThreadTimeline(held);
667 expect(
668 resolveThreadSwitchTimeline({
669 loading: false,
670 activeThreadKey: "env-1:thread-a",
671 nextEntries: [],
672 }),
673 ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" });
674 });
675
676 it("does not hold another environment's timeline across a jump", () => {
677 expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false);
678 expect(
679 resolveThreadSwitchTimeline({
680 loading: true,
681 activeThreadKey: "env-2:thread-b",
682 nextEntries: [],
683 lastReady: held,
684 }),
685 ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" });
686 });
687
688 it("treats a foreign held timeline as paint-only", () => {
689 expect(isPaintOnlyThreadTimeline("env-1:thread-a", "env-1:thread-b")).toBe(true);
690 expect(isPaintOnlyThreadTimeline("env-1:thread-b", "env-1:thread-b")).toBe(false);
691 });
692
693 it("does not remember a timeline that still has handoff blob previews", () => {
694 expect(
695 timelineHasEphemeralPreviewUrls([
696 {
697 kind: "message",
698 message: {
699 id: MessageId.make("preview-message"),
700 role: "user",
701 text: "Preview",
702 turnId: null,
703 streaming: false,
704 createdAt: "2026-09-10T12:00:00.000Z",
705 updatedAt: "2026-09-10T12:00:00.000Z",
706 attachments: [
707 {
708 type: "image",
709 id: "preview",
710 name: "preview.png",
711 mimeType: "image/png",
712 sizeBytes: 1,
713 previewUrl: "blob:handoff",
714 },
715 ],
716 },
717 },
718 ]),
719 ).toBe(true);
720 expect(
721 timelineHasEphemeralPreviewUrls([
722 {
723 kind: "message",
724 message: {
725 id: MessageId.make("preview-message"),
726 role: "user",
727 text: "Preview",
728 turnId: null,
729 streaming: false,
730 createdAt: "2026-09-10T12:00:00.000Z",
731 updatedAt: "2026-09-10T12:00:00.000Z",
732 attachments: [
733 {
734 type: "image",
735 id: "preview",
736 name: "preview.png",
737 mimeType: "image/png",
738 sizeBytes: 1,
739 previewUrl: "https://cdn.example/a.png",
740 },
741 ],
742 },
743 },
744 ]),
745 ).toBe(false);
746 });
747});
748
568749describe("shouldReleaseTimelineAnchorForToolActivity", () => {
569750 const activeTurnId = TurnId.make("active-turn");
570751 const anchorMessageId = MessageId.make("anchored-message");
apps/web/src/components/ChatView.logic.ts+141
......@@ -18,6 +18,7 @@ import {
1818 type ThreadLinkedPullRequest,
1919 type TurnId,
2020} from "@t3tools/contracts";
21import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
2122import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
2223import {
2324 squashAtomCommandFailure,
......@@ -262,6 +263,135 @@ export function resolveDraftHeroState(input: {
262263 );
263264}
264265
266/**
267 * Keep painted timelines on screen across thread jumps. Remounting LegendList
268 * (or handing it an empty first paint) punches a hole through the chat pane —
269 * white in light mode — so cmd+1/2/3 spam flashes even when the destination
270 * is already cached.
271 *
272 * Stored at module scope because ChatView remounts when the thread route
273 * changes (same pattern as the thread-error banner session dismissals).
274 * Remember more than the last thread so jumping back to cmd+1 does not show
275 * cmd+3's messages, and so a cached destination can paint on the first frame.
276 */
277export type HeldThreadTimeline<T extends readonly unknown[]> = {
278 threadKey: string | null;
279 entries: T;
280 markdownCwd?: string | null;
281 workspaceRoot?: string | null;
282};
283
284const MAX_REMEMBERED_THREAD_TIMELINES = 16;
285
286let rememberedThreadTimelines = new Map<string, HeldThreadTimeline<readonly unknown[]>>();
287let rememberedThreadTimelineOrder: string[] = [];
288let lastReadyThreadKey: string | null = null;
289
290function rememberThreadTimelineEntries(held: HeldThreadTimeline<readonly unknown[]>): void {
291 if (held.threadKey === null) {
292 return;
293 }
294 rememberedThreadTimelines.set(held.threadKey, held);
295 rememberedThreadTimelineOrder = [
296 ...rememberedThreadTimelineOrder.filter((key) => key !== held.threadKey),
297 held.threadKey,
298 ];
299 while (rememberedThreadTimelineOrder.length > MAX_REMEMBERED_THREAD_TIMELINES) {
300 const evicted = rememberedThreadTimelineOrder.shift();
301 if (evicted !== undefined) {
302 rememberedThreadTimelines.delete(evicted);
303 }
304 }
305 lastReadyThreadKey = held.threadKey;
306}
307
308export function rememberReadyThreadTimeline<T extends readonly unknown[]>(
309 held: HeldThreadTimeline<T>,
310): void {
311 if (held.threadKey === null || held.entries.length === 0) {
312 return;
313 }
314 rememberThreadTimelineEntries(held);
315}
316
317export function peekRememberedThreadTimeline<T extends readonly unknown[]>(
318 threadKey: string | null,
319): T | null {
320 if (threadKey === null) {
321 return null;
322 }
323 return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null;
324}
325
326export function peekHeldThreadTimeline<
327 T extends readonly unknown[],
328>(): HeldThreadTimeline<T> | null {
329 if (lastReadyThreadKey === null) {
330 return null;
331 }
332 const held = rememberedThreadTimelines.get(lastReadyThreadKey);
333 if (held === undefined || held.entries.length === 0) {
334 return null;
335 }
336 return held as HeldThreadTimeline<T>;
337}
338
339export function resetHeldThreadTimeline(): void {
340 rememberedThreadTimelines = new Map();
341 rememberedThreadTimelineOrder = [];
342 lastReadyThreadKey = null;
343}
344
345export function threadKeysShareEnvironment(left: string | null, right: string | null): boolean {
346 if (left === null || right === null) {
347 return false;
348 }
349 const leftRef = parseScopedThreadKey(left);
350 const rightRef = parseScopedThreadKey(right);
351 return leftRef !== null && rightRef !== null && leftRef.environmentId === rightRef.environmentId;
352}
353
354/** True while we still paint another thread's last snapshot. */
355export function isPaintOnlyThreadTimeline(
356 displayThreadKey: string | null,
357 activeThreadKey: string | null,
358): boolean {
359 return (
360 displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey
361 );
362}
363
364export function resolveThreadSwitchTimeline<T extends readonly unknown[]>(input: {
365 loading: boolean;
366 activeThreadKey: string | null;
367 nextEntries: T;
368 rememberedForActive?: T | null;
369 lastReady?: HeldThreadTimeline<T> | null;
370}): { entries: T; displayThreadKey: string | null } {
371 if (input.nextEntries.length > 0) {
372 return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey };
373 }
374
375 const rememberedForActive =
376 input.rememberedForActive ?? peekRememberedThreadTimeline<T>(input.activeThreadKey);
377 if (input.loading && rememberedForActive !== null && rememberedForActive.length > 0) {
378 return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey };
379 }
380
381 const lastReady = input.lastReady ?? peekHeldThreadTimeline<T>();
382 if (
383 input.loading &&
384 lastReady !== null &&
385 lastReady.threadKey !== null &&
386 lastReady.threadKey !== input.activeThreadKey &&
387 lastReady.entries.length > 0 &&
388 threadKeysShareEnvironment(lastReady.threadKey, input.activeThreadKey)
389 ) {
390 return { entries: lastReady.entries, displayThreadKey: lastReady.threadKey };
391 }
392 return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey };
393}
394
265395export function resolveDraftPromotionNavigationTarget(input: {
266396 serverThreadRef: ScopedThreadRef | null;
267397 serverThread: Pick<Thread, "latestTurn" | "session"> | null | undefined;
......@@ -612,6 +742,17 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
612742 }
613743}
614744
745export function timelineHasEphemeralPreviewUrls(
746 entries: ReadonlyArray<Pick<TimelineEntry, "kind"> & { message?: ChatMessage }>,
747): boolean {
748 return entries.some(
749 (entry) =>
750 entry.kind === "message" &&
751 entry.message !== undefined &&
752 collectUserMessageBlobPreviewUrls(entry.message).length > 0,
753 );
754}
755
615756export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] {
616757 if (message.role !== "user" || !message.attachments) {
617758 return [];
apps/web/src/components/ChatView.tsx+107-28
......@@ -415,6 +415,12 @@ import {
415415 resolveComposerInteractionMode,
416416 resolveComposerProviderSelection,
417417 resolveDraftHeroState,
418 isPaintOnlyThreadTimeline,
419 peekHeldThreadTimeline,
420 peekRememberedThreadTimeline,
421 rememberReadyThreadTimeline,
422 resolveThreadSwitchTimeline,
423 timelineHasEphemeralPreviewUrls,
418424 observeProactivePanelUserChoice,
419425 resolveProactiveTurnDiffAction,
420426 resolveThreadMetadataUpdateForNextTurn,
......@@ -1379,6 +1385,10 @@ function chatActionErrorMessage(error: unknown): string {
13791385}
13801386
13811387const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3;
1388const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = [];
1389const noopHeldTurnDiff = (_turnId: TurnId, _filePath?: string) => {};
1390const noopHeldRevert = (_targetTurnCount: number) => {};
1391const noopHeldAttachment = (_attachment: ChatFileAttachment) => {};
13821392
13831393/**
13841394 * Drops the send-time anchored end space. That space is what holds a sent
......@@ -3239,6 +3249,18 @@ export default function ChatView(props: ChatViewProps) {
32393249 timelineMessages,
32403250 workLogEntries,
32413251 ]);
3252 const displayedTimeline = resolveThreadSwitchTimeline({
3253 loading: timelineEntries.length === 0 && threadSyncPhase !== null,
3254 activeThreadKey,
3255 nextEntries: timelineEntries,
3256 rememberedForActive: peekRememberedThreadTimeline<typeof timelineEntries>(activeThreadKey),
3257 });
3258 const displayedTimelineKey = displayedTimeline.displayThreadKey ?? routeThreadKey;
3259 const paintOnlyDisplayedTimeline = isPaintOnlyThreadTimeline(
3260 displayedTimeline.displayThreadKey,
3261 activeThreadKey,
3262 );
3263 const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey);
32423264 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
32433265 const draftHeroDockRequested =
32443266 activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;
......@@ -3337,6 +3359,24 @@ export default function ChatView(props: ChatViewProps) {
33373359 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
33383360 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
33393361 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
3362 useLayoutEffect(() => {
3363 if (
3364 threadDetailLoading ||
3365 timelineEntries.length === 0 ||
3366 timelineHasEphemeralPreviewUrls(timelineEntries)
3367 ) {
3368 return;
3369 }
3370 rememberReadyThreadTimeline({
3371 threadKey: activeThreadKey,
3372 entries: timelineEntries,
3373 markdownCwd: gitCwd,
3374 workspaceRoot: activeWorkspaceRoot ?? null,
3375 });
3376 }, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]);
3377 const heldPaintContext = paintOnlyDisplayedTimeline
3378 ? peekHeldThreadTimeline<typeof timelineEntries>()
3379 : null;
33403380 const activeTerminalLaunchContext =
33413381 terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;
33423382 // Git status arrives after the composer paints. A checkout seen earlier in
......@@ -4910,6 +4950,21 @@ export default function ChatView(props: ChatViewProps) {
49104950 void legendListRef.current?.scrollToEnd?.({ animated });
49114951 });
49124952 }, []);
4953 const displayedTimelineKeyRef = useRef(displayedTimeline.displayThreadKey);
4954 useLayoutEffect(() => {
4955 const displayKey = displayedTimeline.displayThreadKey;
4956 if (displayKey === null || displayKey !== activeThreadKey) {
4957 displayedTimelineKeyRef.current = displayKey;
4958 return;
4959 }
4960 if (displayedTimelineKeyRef.current === displayKey) {
4961 return;
4962 }
4963 displayedTimelineKeyRef.current = displayKey;
4964 // Keep the list mounted across jumps; pin the newly displayed thread to
4965 // its end the way a remount used to via initialScrollAtEnd.
4966 scrollToEnd();
4967 }, [activeThreadKey, displayedTimeline.displayThreadKey, scrollToEnd]);
49134968 useLayoutEffect(() => {
49144969 if (timelineScrollModeRef.current !== "anchoring-new-turn") {
49154970 return;
......@@ -8435,54 +8490,78 @@ export default function ChatView(props: ChatViewProps) {
84358490 />
84368491 </div>
84378492 {/* Messages Wrapper */}
8438 <div className="relative flex min-h-0 flex-1 flex-col">
8493 <div className="relative flex min-h-0 flex-1 flex-col bg-background">
84398494 {/* Messages — LegendList handles virtualization and scrolling internally */}
84408495 <MessagesTimeline
8441 citationRequest={citationRequest}
8496 citationRequest={paintOnlyDisplayedTimeline ? null : citationRequest}
84428497 citationHistoryLoading={threadDetailLoading}
8443 onCiteAssistantText={citeAssistantText}
8444 agentPanelModel={agentPanelModel}
8445 onOpenAgents={addAgentsSurface}
8446 key={activeThread.id}
8447 isWorking={isWorking}
8448 isPreparingWorktree={isPreparingWorktree}
8449 isCompacting={isCompacting}
8450 activeTurnStartedAt={activeWorkStartedAt}
8498 {...(!paintOnlyDisplayedTimeline
8499 ? {
8500 onCiteAssistantText: citeAssistantText,
8501 agentPanelModel,
8502 onOpenAgents: addAgentsSurface,
8503 onUseArtifactTemplate: useArtifactTemplate,
8504 }
8505 : {})}
8506 isWorking={!paintOnlyDisplayedTimeline && isWorking}
8507 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
8508 isCompacting={!paintOnlyDisplayedTimeline && isCompacting}
8509 activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt}
84518510 listRef={legendListRef}
8452 timelineEntries={timelineEntries}
8453 latestTurn={activeLatestTurn}
8454 runningTurnId={activeRunningTurnId}
8455 turnDiffSummaries={activeThread.checkpoints}
8456 activeThreadEnvironmentId={activeThread.environmentId}
8457 routeThreadKey={routeThreadKey}
8458 onOpenTurnDiff={onOpenTurnDiff}
8459 supportsConversationRollback={supportsConversationRollback}
8460 onRevertToTurnCount={onRevertTimelineTurn}
8461 onUseArtifactTemplate={useArtifactTemplate}
8462 isRevertingCheckpoint={isRevertingCheckpoint}
8511 timelineEntries={displayedTimeline.entries}
8512 latestTurn={paintOnlyDisplayedTimeline ? null : activeLatestTurn}
8513 runningTurnId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId}
8514 turnDiffSummaries={
8515 paintOnlyDisplayedTimeline
8516 ? EMPTY_HELD_TURN_DIFF_SUMMARIES
8517 : activeThread.checkpoints
8518 }
8519 activeThreadEnvironmentId={
8520 displayedThreadRef?.environmentId ?? activeThread.environmentId
8521 }
8522 routeThreadKey={displayedTimelineKey}
8523 displayThreadKey={displayedTimelineKey}
8524 onOpenTurnDiff={paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiff}
8525 supportsConversationRollback={
8526 !paintOnlyDisplayedTimeline && supportsConversationRollback
8527 }
8528 onRevertToTurnCount={
8529 paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn
8530 }
8531 isRevertingCheckpoint={!paintOnlyDisplayedTimeline && isRevertingCheckpoint}
84638532 onImageExpand={onExpandTimelineImage}
8464 onFileOpen={openFileAttachment}
8465 onFileDownload={downloadFileAttachment}
8466 markdownCwd={gitCwd ?? undefined}
8533 onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment}
8534 onFileDownload={
8535 paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachment
8536 }
8537 markdownCwd={
8538 paintOnlyDisplayedTimeline
8539 ? (heldPaintContext?.markdownCwd ?? undefined)
8540 : (gitCwd ?? undefined)
8541 }
84678542 resolvedTheme={resolvedTheme}
84688543 timestampFormat={timestampFormat}
8469 workspaceRoot={activeWorkspaceRoot}
8544 workspaceRoot={
8545 paintOnlyDisplayedTimeline
8546 ? (heldPaintContext?.workspaceRoot ?? undefined)
8547 : activeWorkspaceRoot
8548 }
84708549 skills={
84718550 activeProviderStatus
84728551 ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd)
84738552 : EMPTY_PROVIDER_SKILLS
84748553 }
8475 anchorMessageId={timelineAnchorMessageId}
8554 anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId}
84768555 onAnchorReady={onTimelineAnchorReady}
84778556 contentInsetEndAdjustment={composerTimelineInset}
8478 liveFollowEnabled={timelineLiveFollowEnabled}
8557 liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled}
84798558 onIsAtEndChange={onIsAtEndChange}
84808559 onContentOverflowChange={setTimelineOverflows}
84818560 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}
84828561 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
84838562 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
84848563 topFadeEnabled={!hasTimelineTopBanner}
8485 loadEarlier={loadEarlierTurns}
8564 loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns}
84868565 />
84878566
84888567 {/* scroll to end pill — shown when user has scrolled away from the live edge */}
apps/web/src/components/chat/MessagesTimeline.tsx+43-16
......@@ -331,6 +331,12 @@ interface MessagesTimelineProps {
331331 runningTurnId: TurnId | null;
332332 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
333333 routeThreadKey: string;
334 /**
335 * Thread whose entries are currently painted. Differs from `routeThreadKey`
336 * while a jump is still holding the previous list. Identity for row
337 * projection and list extraData — do not remount on this value.
338 */
339 displayThreadKey?: string;
334340 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
335341 supportsConversationRollback: boolean;
336342 onRevertToTurnCount: (targetTurnCount: number) => void;
......@@ -389,6 +395,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
389395 runningTurnId,
390396 turnDiffSummaries,
391397 routeThreadKey,
398 displayThreadKey,
392399 onOpenTurnDiff,
393400 supportsConversationRollback,
394401 onRevertToTurnCount,
......@@ -416,17 +423,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({
416423 loadEarlier = null,
417424}: MessagesTimelineProps) {
418425 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
426 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
427 const listIdentityKey = displayThreadKey ?? routeThreadKey;
428 const listIdentityRef = useRef(listIdentityKey);
429 const previousLatestTurnRef = useRef(latestTurn);
430 let paintedExpandedTurnIds = expandedTurnIds;
431 let paintedExpandedWorkGroupIds = expandedWorkGroupIds;
432 if (listIdentityRef.current !== listIdentityKey) {
433 listIdentityRef.current = listIdentityKey;
434 previousLatestTurnRef.current = latestTurn;
435 paintedExpandedTurnIds = new Set();
436 paintedExpandedWorkGroupIds = new Set();
437 setExpandedTurnIds(paintedExpandedTurnIds);
438 setExpandedWorkGroupIds(paintedExpandedWorkGroupIds);
439 }
419440 const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]);
420441 const expandCitedTurn = useCallback((turnId: TurnId) => {
421442 setExpandedTurnIds((current) =>
422443 current.has(turnId) ? current : new Set([...current, turnId]),
423444 );
424445 }, []);
425 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
426446 // Scroll/disclosure state outlives virtualized rows, but never the current thread.
427447 const workGroupViewState = useMemo<WorkGroupViewState>(
428448 () => ({ scrollPositions: new Map(), expandedEntries: new Set() }),
429 [routeThreadKey],
449 [listIdentityKey],
430450 );
431451 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
432452 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
......@@ -518,7 +538,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
518538
519539 // An in-session interrupt leaves its turn expanded so the user keeps their
520540 // place; the next turn (or a reload, since this is local state) folds it.
521 const previousLatestTurnRef = useRef(latestTurn);
522541 useEffect(() => {
523542 const previous = previousLatestTurnRef.current;
524543 previousLatestTurnRef.current = latestTurn;
......@@ -557,34 +576,34 @@ export const MessagesTimeline = memo(function MessagesTimeline({
557576 timelineEntries,
558577 latestTurn,
559578 runningTurnId,
560 expandedTurnIds,
561 expandedWorkGroupIds,
579 expandedTurnIds: paintedExpandedTurnIds,
580 expandedWorkGroupIds: paintedExpandedWorkGroupIds,
562581 isWorking,
563582 activeTurnStartedAt,
564583 turnDiffSummaries,
565584 supportsConversationRollback,
566585 },
567 previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot
586 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot
568587 ? previous.projection
569588 : null,
570589 );
571 rowsProjectionRef.current = { threadKey: routeThreadKey, workspaceRoot, projection };
590 rowsProjectionRef.current = { threadKey: listIdentityKey, workspaceRoot, projection };
572591 return projection.rows;
573592 }, [
574593 rowsProjectionRef,
575 routeThreadKey,
594 listIdentityKey,
576595 workspaceRoot,
577596 timelineEntries,
578597 latestTurn,
579598 runningTurnId,
580 expandedTurnIds,
581 expandedWorkGroupIds,
599 paintedExpandedTurnIds,
600 paintedExpandedWorkGroupIds,
582601 isWorking,
583602 activeTurnStartedAt,
584603 turnDiffSummaries,
585604 supportsConversationRollback,
586605 ]);
587 const rows = useStableRows(rawRows);
606 const rows = useStableRows(rawRows, listIdentityKey);
588607 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
589608 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
590609 null,
......@@ -822,7 +841,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
822841
823842 if (rows.length === 0 && !isWorking) {
824843 if (hideEmptyPlaceholder) {
825 return null;
844 // Occupy the pane with the theme surface so a thread switch cannot
845 // punch a hole through to the window chrome (white in light mode).
846 return <div className="h-full min-h-0 bg-background" data-timeline-loading="true" />;
826847 }
827848 return (
828849 <div className="flex h-full items-center justify-center">
......@@ -849,7 +870,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
849870 <LegendList<MessagesTimelineRow>
850871 ref={listRef}
851872 data={rows}
852 extraData={rows.length}
873 extraData={`${listIdentityKey}:${rows.length}`}
853874 keyExtractor={keyExtractor}
854875 getItemType={getItemType}
855876 renderItem={renderItem}
......@@ -2742,17 +2763,23 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
27422763
27432764/** Returns a structurally-shared copy of `rows`: for each row whose content
27442765 * hasn't changed since last call, the previous object reference is reused. */
2745function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {
2766function useStableRows(rows: MessagesTimelineRow[], identity: string): MessagesTimelineRow[] {
27462767 const prevState = useRef<StableMessagesTimelineRowsState>({
27472768 byId: new Map<string, MessagesTimelineRow>(),
27482769 result: [],
27492770 });
2771 const prevIdentity = useRef(identity);
27502772
27512773 return useMemo(() => {
2752 const nextState = computeStableMessagesTimelineRows(rows, prevState.current);
2774 const previous =
2775 prevIdentity.current === identity
2776 ? prevState.current
2777 : { byId: new Map<string, MessagesTimelineRow>(), result: [] };
2778 prevIdentity.current = identity;
2779 const nextState = computeStableMessagesTimelineRows(rows, previous);
27532780 prevState.current = nextState;
27542781 return nextState.result;
2755 }, [rows]);
2782 }, [identity, rows]);
27562783}
27572784
27582785// ---------------------------------------------------------------------------