| author | |
| committer | |
| log | 211618fd9fe39d3dde01171a6856ce9f633571c9 |
| tree | 7017dc6d198b10b494e1a5804f5968da725722a5 |
| parent | 8078c532ceeee5cb951325031f216171507f3d5e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 567 | 575 | ||
| 576 | describe("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 | |||
| 568 | describe("shouldReleaseTimelineAnchorForToolActivity", () => { | 749 | describe("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"; |
| 21 | import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; | ||
| 21 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | 22 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 22 | import { | 23 | import { |
| 23 | squashAtomCommandFailure, | 24 | squashAtomCommandFailure, |
| ... | @@ -262,6 +263,135 @@ export function resolveDraftHeroState(input: { | ... | @@ -262,6 +263,135 @@ export function resolveDraftHeroState(input: { |
| 262 | ); | 263 | ); |
| 263 | } | 264 | } |
| 264 | 265 | ||
| 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 | */ | ||
| 277 | export type HeldThreadTimeline<T extends readonly unknown[]> = { | ||
| 278 | threadKey: string | null; | ||
| 279 | entries: T; | ||
| 280 | markdownCwd?: string | null; | ||
| 281 | workspaceRoot?: string | null; | ||
| 282 | }; | ||
| 283 | |||
| 284 | const MAX_REMEMBERED_THREAD_TIMELINES = 16; | ||
| 285 | |||
| 286 | let rememberedThreadTimelines = new Map<string, HeldThreadTimeline<readonly unknown[]>>(); | ||
| 287 | let rememberedThreadTimelineOrder: string[] = []; | ||
| 288 | let lastReadyThreadKey: string | null = null; | ||
| 289 | |||
| 290 | function 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 | |||
| 308 | export 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 | |||
| 317 | export 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 | |||
| 326 | export 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 | |||
| 339 | export function resetHeldThreadTimeline(): void { | ||
| 340 | rememberedThreadTimelines = new Map(); | ||
| 341 | rememberedThreadTimelineOrder = []; | ||
| 342 | lastReadyThreadKey = null; | ||
| 343 | } | ||
| 344 | |||
| 345 | export 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. */ | ||
| 355 | export function isPaintOnlyThreadTimeline( | ||
| 356 | displayThreadKey: string | null, | ||
| 357 | activeThreadKey: string | null, | ||
| 358 | ): boolean { | ||
| 359 | return ( | ||
| 360 | displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey | ||
| 361 | ); | ||
| 362 | } | ||
| 363 | |||
| 364 | export 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 | |||
| 265 | export function resolveDraftPromotionNavigationTarget(input: { | 395 | export 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 | } |
| 614 | 744 | ||
| 745 | export 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 | |||
| 615 | export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { | 756 | export 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 | } |
| 1380 | 1386 | ||
| 1381 | const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; | 1387 | const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; |
| 1388 | const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; | ||
| 1389 | const noopHeldTurnDiff = (_turnId: TurnId, _filePath?: string) => {}; | ||
| 1390 | const noopHeldRevert = (_targetTurnCount: number) => {}; | ||
| 1391 | const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; | ||
| 1382 | 1392 | ||
| 1383 | /** | 1393 | /** |
| 1384 | * Drops the send-time anchored end space. That space is what holds a sent | 1394 | * 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 in | 3382 | // 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 | <MessagesTimeline | 8495 | <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 | activeProviderStatus | 8550 | activeProviderStatus |
| 8472 | ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) | 8551 | ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) |
| 8473 | : EMPTY_PROVIDER_SKILLS | 8552 | : 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 | /> |
| 8487 | 8566 | ||
| 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({ |
| 518 | 538 | ||
| 519 | // An in-session interrupt leaves its turn expanded so the user keeps their | 539 | // 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 === workspaceRoot | 586 | previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot |
| 568 | ? previous.projection | 587 | ? 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({ |
| 822 | 841 | ||
| 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 |
| 2742 | 2763 | ||
| 2743 | /** Returns a structurally-shared copy of `rows`: for each row whose content | 2764 | /** 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. */ |
| 2745 | function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { | 2766 | function 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); | ||
| 2750 | 2772 | ||
| 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 | } |
| 2757 | 2784 | ||
| 2758 | // --------------------------------------------------------------------------- | 2785 | // --------------------------------------------------------------------------- |