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 {...@@ -59,6 +59,14 @@ import {
59 resolveSendEnvMode,59 resolveSendEnvMode,
60 threadShellHasStarted,60 threadShellHasStarted,
61 resolveDraftHeroState,61 resolveDraftHeroState,
62 isPaintOnlyThreadTimeline,
63 peekHeldThreadTimeline,
64 peekRememberedThreadTimeline,
65 rememberReadyThreadTimeline,
66 resetHeldThreadTimeline,
67 resolveThreadSwitchTimeline,
68 threadKeysShareEnvironment,
69 timelineHasEphemeralPreviewUrls,
62 scheduleEnvironmentReconnectWarning,70 scheduleEnvironmentReconnectWarning,
63 startNewThreadForProject,71 startNewThreadForProject,
64 codexArtifactTemplatePromptToAppend,72 codexArtifactTemplatePromptToAppend,
...@@ -565,6 +573,179 @@ describe("draft hero submission transition", () => {...@@ -565,6 +573,179 @@ describe("draft hero submission transition", () => {
565 });573 });
566});574});
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
568describe("shouldReleaseTimelineAnchorForToolActivity", () => {749describe("shouldReleaseTimelineAnchorForToolActivity", () => {
569 const activeTurnId = TurnId.make("active-turn");750 const activeTurnId = TurnId.make("active-turn");
570 const anchorMessageId = MessageId.make("anchored-message");751 const anchorMessageId = MessageId.make("anchored-message");
apps/web/src/components/ChatView.logic.ts+141
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 type ThreadLinkedPullRequest,18 type ThreadLinkedPullRequest,
19 type TurnId,19 type TurnId,
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
21import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";22import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
22import {23import {
23 squashAtomCommandFailure,24 squashAtomCommandFailure,
...@@ -262,6 +263,135 @@ export function resolveDraftHeroState(input: {...@@ -262,6 +263,135 @@ export function resolveDraftHeroState(input: {
262 );263 );
263}264}
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
265export function resolveDraftPromotionNavigationTarget(input: {395export function resolveDraftPromotionNavigationTarget(input: {
266 serverThreadRef: ScopedThreadRef | null;396 serverThreadRef: ScopedThreadRef | null;
267 serverThread: Pick<Thread, "latestTurn" | "session"> | null | undefined;397 serverThread: Pick<Thread, "latestTurn" | "session"> | null | undefined;
...@@ -612,6 +742,17 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void {...@@ -612,6 +742,17 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
612 }742 }
613}743}
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
615export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] {756export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] {
616 if (message.role !== "user" || !message.attachments) {757 if (message.role !== "user" || !message.attachments) {
617 return [];758 return [];
apps/web/src/components/ChatView.tsx+107-28
...@@ -415,6 +415,12 @@ import {...@@ -415,6 +415,12 @@ import {
415 resolveComposerInteractionMode,415 resolveComposerInteractionMode,
416 resolveComposerProviderSelection,416 resolveComposerProviderSelection,
417 resolveDraftHeroState,417 resolveDraftHeroState,
418 isPaintOnlyThreadTimeline,
419 peekHeldThreadTimeline,
420 peekRememberedThreadTimeline,
421 rememberReadyThreadTimeline,
422 resolveThreadSwitchTimeline,
423 timelineHasEphemeralPreviewUrls,
418 observeProactivePanelUserChoice,424 observeProactivePanelUserChoice,
419 resolveProactiveTurnDiffAction,425 resolveProactiveTurnDiffAction,
420 resolveThreadMetadataUpdateForNextTurn,426 resolveThreadMetadataUpdateForNextTurn,
...@@ -1379,6 +1385,10 @@ function chatActionErrorMessage(error: unknown): string {...@@ -1379,6 +1385,10 @@ function chatActionErrorMessage(error: unknown): string {
1379}1385}
13801386
1381const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3;1387const 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
1383/**1393/**
1384 * Drops the send-time anchored end space. That space is what holds a sent1394 * Drops the send-time anchored end space. That space is what holds a sent
...@@ -3239,6 +3249,18 @@ export default function ChatView(props: ChatViewProps) {...@@ -3239,6 +3249,18 @@ export default function ChatView(props: ChatViewProps) {
3239 timelineMessages,3249 timelineMessages,
3240 workLogEntries,3250 workLogEntries,
3241 ]);3251 ]);
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);
3242 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);3264 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
3243 const draftHeroDockRequested =3265 const draftHeroDockRequested =
3244 activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;3266 activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;
...@@ -3337,6 +3359,24 @@ export default function ChatView(props: ChatViewProps) {...@@ -3337,6 +3359,24 @@ export default function ChatView(props: ChatViewProps) {
3337 const activeProjectCwd = activeProject?.workspaceRoot ?? null;3359 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
3338 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;3360 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
3339 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;3361 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;
3340 const activeTerminalLaunchContext =3380 const activeTerminalLaunchContext =
3341 terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;3381 terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;
3342 // Git status arrives after the composer paints. A checkout seen earlier in3382 // Git status arrives after the composer paints. A checkout seen earlier in
...@@ -4910,6 +4950,21 @@ export default function ChatView(props: ChatViewProps) {...@@ -4910,6 +4950,21 @@ export default function ChatView(props: ChatViewProps) {
4910 void legendListRef.current?.scrollToEnd?.({ animated });4950 void legendListRef.current?.scrollToEnd?.({ animated });
4911 });4951 });
4912 }, []);4952 }, []);
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]);
4913 useLayoutEffect(() => {4968 useLayoutEffect(() => {
4914 if (timelineScrollModeRef.current !== "anchoring-new-turn") {4969 if (timelineScrollModeRef.current !== "anchoring-new-turn") {
4915 return;4970 return;
...@@ -8435,54 +8490,78 @@ export default function ChatView(props: ChatViewProps) {...@@ -8435,54 +8490,78 @@ export default function ChatView(props: ChatViewProps) {
8435 />8490 />
8436 </div>8491 </div>
8437 {/* Messages Wrapper */}8492 {/* 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">
8439 {/* Messages — LegendList handles virtualization and scrolling internally */}8494 {/* Messages — LegendList handles virtualization and scrolling internally */}
8440 <MessagesTimeline8495 <MessagesTimeline
8441 citationRequest={citationRequest}8496 citationRequest={paintOnlyDisplayedTimeline ? null : citationRequest}
8442 citationHistoryLoading={threadDetailLoading}8497 citationHistoryLoading={threadDetailLoading}
8443 onCiteAssistantText={citeAssistantText}8498 {...(!paintOnlyDisplayedTimeline
8444 agentPanelModel={agentPanelModel}8499 ? {
8445 onOpenAgents={addAgentsSurface}8500 onCiteAssistantText: citeAssistantText,
8446 key={activeThread.id}8501 agentPanelModel,
8447 isWorking={isWorking}8502 onOpenAgents: addAgentsSurface,
8448 isPreparingWorktree={isPreparingWorktree}8503 onUseArtifactTemplate: useArtifactTemplate,
8449 isCompacting={isCompacting}8504 }
8450 activeTurnStartedAt={activeWorkStartedAt}8505 : {})}
8506 isWorking={!paintOnlyDisplayedTimeline && isWorking}
8507 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
8508 isCompacting={!paintOnlyDisplayedTimeline && isCompacting}
8509 activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt}
8451 listRef={legendListRef}8510 listRef={legendListRef}
8452 timelineEntries={timelineEntries}8511 timelineEntries={displayedTimeline.entries}
8453 latestTurn={activeLatestTurn}8512 latestTurn={paintOnlyDisplayedTimeline ? null : activeLatestTurn}
8454 runningTurnId={activeRunningTurnId}8513 runningTurnId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId}
8455 turnDiffSummaries={activeThread.checkpoints}8514 turnDiffSummaries={
8456 activeThreadEnvironmentId={activeThread.environmentId}8515 paintOnlyDisplayedTimeline
8457 routeThreadKey={routeThreadKey}8516 ? EMPTY_HELD_TURN_DIFF_SUMMARIES
8458 onOpenTurnDiff={onOpenTurnDiff}8517 : activeThread.checkpoints
8459 supportsConversationRollback={supportsConversationRollback}8518 }
8460 onRevertToTurnCount={onRevertTimelineTurn}8519 activeThreadEnvironmentId={
8461 onUseArtifactTemplate={useArtifactTemplate}8520 displayedThreadRef?.environmentId ?? activeThread.environmentId
8462 isRevertingCheckpoint={isRevertingCheckpoint}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}
8463 onImageExpand={onExpandTimelineImage}8532 onImageExpand={onExpandTimelineImage}
8464 onFileOpen={openFileAttachment}8533 onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment}
8465 onFileDownload={downloadFileAttachment}8534 onFileDownload={
8466 markdownCwd={gitCwd ?? undefined}8535 paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachment
8536 }
8537 markdownCwd={
8538 paintOnlyDisplayedTimeline
8539 ? (heldPaintContext?.markdownCwd ?? undefined)
8540 : (gitCwd ?? undefined)
8541 }
8467 resolvedTheme={resolvedTheme}8542 resolvedTheme={resolvedTheme}
8468 timestampFormat={timestampFormat}8543 timestampFormat={timestampFormat}
8469 workspaceRoot={activeWorkspaceRoot}8544 workspaceRoot={
8545 paintOnlyDisplayedTimeline
8546 ? (heldPaintContext?.workspaceRoot ?? undefined)
8547 : activeWorkspaceRoot
8548 }
8470 skills={8549 skills={
8471 activeProviderStatus8550 activeProviderStatus
8472 ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd)8551 ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd)
8473 : EMPTY_PROVIDER_SKILLS8552 : EMPTY_PROVIDER_SKILLS
8474 }8553 }
8475 anchorMessageId={timelineAnchorMessageId}8554 anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId}
8476 onAnchorReady={onTimelineAnchorReady}8555 onAnchorReady={onTimelineAnchorReady}
8477 contentInsetEndAdjustment={composerTimelineInset}8556 contentInsetEndAdjustment={composerTimelineInset}
8478 liveFollowEnabled={timelineLiveFollowEnabled}8557 liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled}
8479 onIsAtEndChange={onIsAtEndChange}8558 onIsAtEndChange={onIsAtEndChange}
8480 onContentOverflowChange={setTimelineOverflows}8559 onContentOverflowChange={setTimelineOverflows}
8481 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}8560 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}
8482 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}8561 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
8483 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}8562 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
8484 topFadeEnabled={!hasTimelineTopBanner}8563 topFadeEnabled={!hasTimelineTopBanner}
8485 loadEarlier={loadEarlierTurns}8564 loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns}
8486 />8565 />
84878566
8488 {/* scroll to end pill — shown when user has scrolled away from the live edge */}8567 {/* 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 {...@@ -331,6 +331,12 @@ interface MessagesTimelineProps {
331 runningTurnId: TurnId | null;331 runningTurnId: TurnId | null;
332 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;332 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
333 routeThreadKey: string;333 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;
334 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;340 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
335 supportsConversationRollback: boolean;341 supportsConversationRollback: boolean;
336 onRevertToTurnCount: (targetTurnCount: number) => void;342 onRevertToTurnCount: (targetTurnCount: number) => void;
...@@ -389,6 +395,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -389,6 +395,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
389 runningTurnId,395 runningTurnId,
390 turnDiffSummaries,396 turnDiffSummaries,
391 routeThreadKey,397 routeThreadKey,
398 displayThreadKey,
392 onOpenTurnDiff,399 onOpenTurnDiff,
393 supportsConversationRollback,400 supportsConversationRollback,
394 onRevertToTurnCount,401 onRevertToTurnCount,
...@@ -416,17 +423,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -416,17 +423,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({
416 loadEarlier = null,423 loadEarlier = null,
417}: MessagesTimelineProps) {424}: MessagesTimelineProps) {
418 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());425 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 }
419 const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]);440 const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]);
420 const expandCitedTurn = useCallback((turnId: TurnId) => {441 const expandCitedTurn = useCallback((turnId: TurnId) => {
421 setExpandedTurnIds((current) =>442 setExpandedTurnIds((current) =>
422 current.has(turnId) ? current : new Set([...current, turnId]),443 current.has(turnId) ? current : new Set([...current, turnId]),
423 );444 );
424 }, []);445 }, []);
425 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
426 // Scroll/disclosure state outlives virtualized rows, but never the current thread.446 // Scroll/disclosure state outlives virtualized rows, but never the current thread.
427 const workGroupViewState = useMemo<WorkGroupViewState>(447 const workGroupViewState = useMemo<WorkGroupViewState>(
428 () => ({ scrollPositions: new Map(), expandedEntries: new Set() }),448 () => ({ scrollPositions: new Map(), expandedEntries: new Set() }),
429 [routeThreadKey],449 [listIdentityKey],
430 );450 );
431 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);451 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
432 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());452 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
...@@ -518,7 +538,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -518,7 +538,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
518538
519 // An in-session interrupt leaves its turn expanded so the user keeps their539 // An in-session interrupt leaves its turn expanded so the user keeps their
520 // place; the next turn (or a reload, since this is local state) folds it.540 // place; the next turn (or a reload, since this is local state) folds it.
521 const previousLatestTurnRef = useRef(latestTurn);
522 useEffect(() => {541 useEffect(() => {
523 const previous = previousLatestTurnRef.current;542 const previous = previousLatestTurnRef.current;
524 previousLatestTurnRef.current = latestTurn;543 previousLatestTurnRef.current = latestTurn;
...@@ -557,34 +576,34 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -557,34 +576,34 @@ export const MessagesTimeline = memo(function MessagesTimeline({
557 timelineEntries,576 timelineEntries,
558 latestTurn,577 latestTurn,
559 runningTurnId,578 runningTurnId,
560 expandedTurnIds,579 expandedTurnIds: paintedExpandedTurnIds,
561 expandedWorkGroupIds,580 expandedWorkGroupIds: paintedExpandedWorkGroupIds,
562 isWorking,581 isWorking,
563 activeTurnStartedAt,582 activeTurnStartedAt,
564 turnDiffSummaries,583 turnDiffSummaries,
565 supportsConversationRollback,584 supportsConversationRollback,
566 },585 },
567 previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot586 previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot
568 ? previous.projection587 ? previous.projection
569 : null,588 : null,
570 );589 );
571 rowsProjectionRef.current = { threadKey: routeThreadKey, workspaceRoot, projection };590 rowsProjectionRef.current = { threadKey: listIdentityKey, workspaceRoot, projection };
572 return projection.rows;591 return projection.rows;
573 }, [592 }, [
574 rowsProjectionRef,593 rowsProjectionRef,
575 routeThreadKey,594 listIdentityKey,
576 workspaceRoot,595 workspaceRoot,
577 timelineEntries,596 timelineEntries,
578 latestTurn,597 latestTurn,
579 runningTurnId,598 runningTurnId,
580 expandedTurnIds,599 paintedExpandedTurnIds,
581 expandedWorkGroupIds,600 paintedExpandedWorkGroupIds,
582 isWorking,601 isWorking,
583 activeTurnStartedAt,602 activeTurnStartedAt,
584 turnDiffSummaries,603 turnDiffSummaries,
585 supportsConversationRollback,604 supportsConversationRollback,
586 ]);605 ]);
587 const rows = useStableRows(rawRows);606 const rows = useStableRows(rawRows, listIdentityKey);
588 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);607 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
589 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(608 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
590 null,609 null,
...@@ -822,7 +841,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -822,7 +841,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
822841
823 if (rows.length === 0 && !isWorking) {842 if (rows.length === 0 && !isWorking) {
824 if (hideEmptyPlaceholder) {843 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" />;
826 }847 }
827 return (848 return (
828 <div className="flex h-full items-center justify-center">849 <div className="flex h-full items-center justify-center">
...@@ -849,7 +870,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -849,7 +870,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
849 <LegendList<MessagesTimelineRow>870 <LegendList<MessagesTimelineRow>
850 ref={listRef}871 ref={listRef}
851 data={rows}872 data={rows}
852 extraData={rows.length}873 extraData={`${listIdentityKey}:${rows.length}`}
853 keyExtractor={keyExtractor}874 keyExtractor={keyExtractor}
854 getItemType={getItemType}875 getItemType={getItemType}
855 renderItem={renderItem}876 renderItem={renderItem}
...@@ -2742,17 +2763,23 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte...@@ -2742,17 +2763,23 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
27422763
2743/** Returns a structurally-shared copy of `rows`: for each row whose content2764/** Returns a structurally-shared copy of `rows`: for each row whose content
2744 * hasn't changed since last call, the previous object reference is reused. */2765 * hasn't changed since last call, the previous object reference is reused. */
2745function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {2766function useStableRows(rows: MessagesTimelineRow[], identity: string): MessagesTimelineRow[] {
2746 const prevState = useRef<StableMessagesTimelineRowsState>({2767 const prevState = useRef<StableMessagesTimelineRowsState>({
2747 byId: new Map<string, MessagesTimelineRow>(),2768 byId: new Map<string, MessagesTimelineRow>(),
2748 result: [],2769 result: [],
2749 });2770 });
2771 const prevIdentity = useRef(identity);
27502772
2751 return useMemo(() => {2773 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);
2753 prevState.current = nextState;2780 prevState.current = nextState;
2754 return nextState.result;2781 return nextState.result;
2755 }, [rows]);2782 }, [identity, rows]);
2756}2783}
27572784
2758// ---------------------------------------------------------------------------2785// ---------------------------------------------------------------------------