From f5f8af6cf8baad51e7c517445d755a573276cd38 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 16:41:10 -0700 Subject: [PATCH 1/2] perf(web): timeline updates and keydowns no longer stall long threads - Turn folds look up their own entries instead of scanning the whole timeline per fold (quadratic in long threads). - A changed tool/work item re-derives only its own timeline entry instead of rebuilding every entry. - isModelPickerOpen() reads a mount count instead of querying the document on every keydown, once per shortcut scope. Co-Authored-By: Claude Opus 5.5 --- .../components/chat/MessagesTimeline.logic.ts | 15 +++--- .../components/chat/ModelPickerContent.tsx | 3 ++ apps/web/src/modelPickerVisibility.ts | 15 ++++-- apps/web/src/session-logic.test.ts | 18 +++++++ apps/web/src/session-logic.ts | 48 +++++++++++++++++-- 5 files changed, 84 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4ea9e852edfba..f4b409f901b53 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1436,6 +1436,7 @@ export function deriveMessagesTimelineRows(input: { liveSubagentEntryIds, completeSupersededReplyEntryIds: completeSupersededReplyIds, }); + const timelineEntryById = new Map(timelineEntries.map((entry) => [entry.id, entry])); const collapsedEntryIds = new Set(); const collapsedFoldMemberIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { @@ -1681,14 +1682,14 @@ export function deriveMessagesTimelineRows(input: { const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); if (turnFold) { - const deferredWorkItemIds = timelineEntries.flatMap((entry) => - turnFold.entryIds.has(entry.id) && - entry.kind === "work" && - entry.entry.projectedItem?.item.deferredWork && - !supersededWorkItemIds.has(String(entry.entry.projectedItem.sourceItemId)) + const deferredWorkItemIds = [...turnFold.entryIds].flatMap((entryId) => { + const entry = timelineEntryById.get(entryId); + return entry?.kind === "work" && + entry.entry.projectedItem?.item.deferredWork && + !supersededWorkItemIds.has(String(entry.entry.projectedItem.sourceItemId)) ? [String(entry.entry.projectedItem.sourceItemId)] - : [], - ); + : []; + }); nextRows.push({ kind: "turn-fold", id: `turn-fold:${turnFold.runId}`, diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index b7430716f96f9..c5a8cdf64c18f 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -41,6 +41,7 @@ import { } from "../ui/combobox"; import { ModelEsque } from "./providerIconUtils"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { registerModelPickerContent } from "../../modelPickerVisibility"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { modelPickerJumpCommandForIndex, @@ -377,6 +378,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const [searchQuery, setSearchQuery] = useState(""); const modelListRef = useRef(null); const pickerContentRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes this picker's mount to global keydown handlers + useLayoutEffect(() => registerModelPickerContent(), []); const highlightedModelKeyRef = useRef(null); const { favorites } = useEnvironmentModelPreferences(props.environmentId); const activeEntry = props.instanceEntries.find( diff --git a/apps/web/src/modelPickerVisibility.ts b/apps/web/src/modelPickerVisibility.ts index a8177fe83b5c6..17de95380fb87 100644 --- a/apps/web/src/modelPickerVisibility.ts +++ b/apps/web/src/modelPickerVisibility.ts @@ -1,8 +1,13 @@ -const MODEL_PICKER_CONTENT_SELECTOR = "[data-model-picker-content]"; +// Keydown handlers read this on every key, so a mount count replaces a document query. +let mountedModelPickers = 0; + +export function registerModelPickerContent(): () => void { + mountedModelPickers += 1; + return () => { + mountedModelPickers -= 1; + }; +} export function isModelPickerOpen(): boolean { - return ( - typeof document !== "undefined" && - document.querySelector(MODEL_PICKER_CONTENT_SELECTOR) !== null - ); + return mountedModelPickers > 0; } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f905376dc341a..4d0c46429298d 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1472,6 +1472,24 @@ describe("incremental v2 timeline entries", () => { expect(next.entries[index]).toBe(entry); }); + it("re-derives only the work entry whose tool item changed", () => { + const fixture = makeStreamingTimelineFixture("Partial"); + const visibleTurnItems = fixture.visibleTurnItems.map((row) => + row.item.id === "live-work" + ? { ...row, item: { ...row.item, status: "running" as const, completedAt: null } } + : row, + ); + const input = { visibleTurnItems, optimisticMessages: [] }; + const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); + const nextInput = { ...input, visibleTurnItems: fixture.visibleTurnItems }; + const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); + expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); + for (const [index, entry] of previous.entries.entries()) { + if (entry.id === "live-work") expect(next.entries[index]).not.toBe(entry); + else expect(next.entries[index]).toBe(entry); + } + }); + it.each(["completion", "provenance", "ordering", "attachment", "run"] as const)( "rebuilds entries for a %s change instead of retaining stale v2 metadata", (change) => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 37d7ad27be5a7..f75091411eae0 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -903,6 +903,33 @@ export function isStreamingTurnItemTextUpdate( return Equal.equals(previousMetadata, nextMetadata); } +/** + * A changed tool or work item maps to exactly one entry with the item's id. + * Messages and question answers take part in cross-entry dedupe, so they rebuild. + */ +function rederivedNonMessageEntry( + input: TimelineEntriesInput, + previousItem: OrchestrationV2ProjectedTurnItem, + item: OrchestrationV2ProjectedTurnItem, +): TimelineEntry | null { + if ( + item.item.id !== previousItem.item.id || + item.item.type !== previousItem.item.type || + item.item.type === "user_message" || + item.item.type === "assistant_message" || + item.item.type === "user_input_request" + ) { + return null; + } + const entries = deriveTimelineEntriesFromVisibleTurnItems({ + ...input, + visibleTurnItems: [item], + optimisticMessages: [], + anchoredMessages: [], + }); + return entries.length === 1 && entries[0]!.id === item.item.id ? entries[0]! : null; +} + function reuseTimelineEntries( input: TimelineEntriesInput, previous: TimelineEntriesProjection, @@ -943,15 +970,29 @@ function reuseTimelineEntries( OrchestrationV2ProjectedTurnItem, OrchestrationV2ProjectedTurnItem >(); + const rederivedEntries = new Map(); for (let index = 0; index < before.visibleTurnItems.length; index += 1) { const previousItem = before.visibleTurnItems[index]!; const item = input.visibleTurnItems[index]!; if (item === previousItem) continue; - if (!isStreamingTurnItemTextUpdate(previousItem, item)) return null; - replacements.set(previousItem, item); + if (isStreamingTurnItemTextUpdate(previousItem, item)) { + replacements.set(previousItem, item); + continue; + } + const entry = rederivedNonMessageEntry(input, previousItem, item); + if (entry === null) return null; + rederivedEntries.set(entry.id, entry); + } + if (replacements.size === 0 && rederivedEntries.size === 0 && !appended) { + return previous.entries; } - if (replacements.size === 0 && !appended) return previous.entries; + let rederivedCount = 0; const entries = previous.entries.map((entry): TimelineEntry => { + const rederived = rederivedEntries.get(entry.id); + if (rederived !== undefined) { + rederivedCount += 1; + return rederived; + } const row = entry.kind === "message" && entry.projectedItem !== undefined ? replacements.get(entry.projectedItem) @@ -967,6 +1008,7 @@ function reuseTimelineEntries( }, }; }); + if (rederivedCount !== rederivedEntries.size) return null; if (appended) { entries.push( ...deriveTimelineEntriesFromVisibleTurnItems({ From 7e86a3cab83d538d383429981608cf207828d89a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 17:12:12 -0700 Subject: [PATCH 2/2] fix(web): batch in-place timeline entry updates and respect anchored order Re-derive all changed tool/work items in one call so the attempt, node, and plan lookups build once per update instead of once per changed item. Fall back to a full rebuild when a changed item's createdAt moves while anchored messages are present, since those are placed by createdAt. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/session-logic.test.ts | 27 +++++++++++++ apps/web/src/session-logic.ts | 62 +++++++++++++++++------------- 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 4d0c46429298d..9c4e38c30e510 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1488,6 +1488,33 @@ describe("incremental v2 timeline entries", () => { if (entry.id === "live-work") expect(next.entries[index]).not.toBe(entry); else expect(next.entries[index]).toBe(entry); } + + const anchoredInput = { + ...nextInput, + anchoredMessages: [ + { + id: MessageId.make("feedback"), + runId: null, + role: "assistant" as const, + text: "Feedback received", + streaming: false, + createdAt: fixture.time(6), + updatedAt: fixture.time(6), + }, + ], + }; + const anchored = deriveTimelineEntriesFromVisibleTurnItemsWithState(anchoredInput); + const movedInput = { + ...anchoredInput, + visibleTurnItems: anchoredInput.visibleTurnItems.map((row) => + row.item.id === "live-work" + ? { ...row, item: { ...row.item, startedAt: DateTime.makeUnsafe(fixture.time(8)) } } + : row, + ), + }; + expect( + deriveTimelineEntriesFromVisibleTurnItemsWithState(movedInput, anchored).entries, + ).toEqual(deriveTimelineEntriesFromVisibleTurnItems(movedInput)); }); it.each(["completion", "provenance", "ordering", "attachment", "run"] as const)( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index f75091411eae0..7b79dea6f7401 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -905,29 +905,23 @@ export function isStreamingTurnItemTextUpdate( /** * A changed tool or work item maps to exactly one entry with the item's id. - * Messages and question answers take part in cross-entry dedupe, so they rebuild. + * Messages and question answers take part in cross-entry dedupe, and anchored + * messages are placed by createdAt, so those changes rebuild. */ -function rederivedNonMessageEntry( +function canRederiveInPlace( input: TimelineEntriesInput, previousItem: OrchestrationV2ProjectedTurnItem, item: OrchestrationV2ProjectedTurnItem, -): TimelineEntry | null { - if ( - item.item.id !== previousItem.item.id || - item.item.type !== previousItem.item.type || - item.item.type === "user_message" || - item.item.type === "assistant_message" || - item.item.type === "user_input_request" - ) { - return null; - } - const entries = deriveTimelineEntriesFromVisibleTurnItems({ - ...input, - visibleTurnItems: [item], - optimisticMessages: [], - anchoredMessages: [], - }); - return entries.length === 1 && entries[0]!.id === item.item.id ? entries[0]! : null; +): boolean { + return ( + item.item.id === previousItem.item.id && + item.item.type === previousItem.item.type && + item.item.type !== "user_message" && + item.item.type !== "assistant_message" && + item.item.type !== "user_input_request" && + ((input.anchoredMessages?.length ?? 0) === 0 || + projectedItemCreatedAt(item) === projectedItemCreatedAt(previousItem)) + ); } function reuseTimelineEntries( @@ -970,7 +964,7 @@ function reuseTimelineEntries( OrchestrationV2ProjectedTurnItem, OrchestrationV2ProjectedTurnItem >(); - const rederivedEntries = new Map(); + const changedItems: OrchestrationV2ProjectedTurnItem[] = []; for (let index = 0; index < before.visibleTurnItems.length; index += 1) { const previousItem = before.visibleTurnItems[index]!; const item = input.visibleTurnItems[index]!; @@ -979,19 +973,35 @@ function reuseTimelineEntries( replacements.set(previousItem, item); continue; } - const entry = rederivedNonMessageEntry(input, previousItem, item); - if (entry === null) return null; - rederivedEntries.set(entry.id, entry); + if (!canRederiveInPlace(input, previousItem, item)) return null; + changedItems.push(item); + } + // One batch keeps the attempt/node/plan lookups to a single build. + const rederived = + changedItems.length === 0 + ? [] + : deriveTimelineEntriesFromVisibleTurnItems({ + ...input, + visibleTurnItems: changedItems, + optimisticMessages: [], + anchoredMessages: [], + }); + if ( + rederived.length !== changedItems.length || + rederived.some((entry, index) => entry.id !== changedItems[index]!.item.id) + ) { + return null; } + const rederivedEntries = new Map(rederived.map((entry) => [entry.id, entry])); if (replacements.size === 0 && rederivedEntries.size === 0 && !appended) { return previous.entries; } let rederivedCount = 0; const entries = previous.entries.map((entry): TimelineEntry => { - const rederived = rederivedEntries.get(entry.id); - if (rederived !== undefined) { + const rederivedEntry = rederivedEntries.get(entry.id); + if (rederivedEntry !== undefined) { rederivedCount += 1; - return rederived; + return rederivedEntry; } const row = entry.kind === "message" && entry.projectedItem !== undefined