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..9c4e38c30e510 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1472,6 +1472,51 @@ 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); + } + + 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)( "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..7b79dea6f7401 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -903,6 +903,27 @@ 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, and anchored + * messages are placed by createdAt, so those changes rebuild. + */ +function canRederiveInPlace( + input: TimelineEntriesInput, + previousItem: OrchestrationV2ProjectedTurnItem, + item: OrchestrationV2ProjectedTurnItem, +): 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( input: TimelineEntriesInput, previous: TimelineEntriesProjection, @@ -943,15 +964,45 @@ function reuseTimelineEntries( OrchestrationV2ProjectedTurnItem, OrchestrationV2ProjectedTurnItem >(); + const changedItems: OrchestrationV2ProjectedTurnItem[] = []; 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; + } + if (!canRederiveInPlace(input, previousItem, item)) return null; + changedItems.push(item); } - if (replacements.size === 0 && !appended) return previous.entries; + // 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 rederivedEntry = rederivedEntries.get(entry.id); + if (rederivedEntry !== undefined) { + rederivedCount += 1; + return rederivedEntry; + } const row = entry.kind === "message" && entry.projectedItem !== undefined ? replacements.get(entry.projectedItem) @@ -967,6 +1018,7 @@ function reuseTimelineEntries( }, }; }); + if (rederivedCount !== rederivedEntries.size) return null; if (appended) { entries.push( ...deriveTimelineEntriesFromVisibleTurnItems({