Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
const collapsedFoldMemberIds = new Set<string>();
for (const fold of foldsByAnchorEntryId.values()) {
Expand Down Expand Up @@ -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}`,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/ModelPickerContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -377,6 +378,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
const [searchQuery, setSearchQuery] = useState("");
const modelListRef = useRef<LegendListRef | null>(null);
const pickerContentRef = useRef<HTMLDivElement>(null);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes this picker's mount to global keydown handlers
useLayoutEffect(() => registerModelPickerContent(), []);
const highlightedModelKeyRef = useRef<string | null>(null);
const { favorites } = useEnvironmentModelPreferences(props.environmentId);
const activeEntry = props.instanceEntries.find(
Expand Down
15 changes: 10 additions & 5 deletions apps/web/src/modelPickerVisibility.ts
Original file line number Diff line number Diff line change
@@ -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;
}
45 changes: 45 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
58 changes: 55 additions & 3 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -967,6 +1018,7 @@ function reuseTimelineEntries(
},
};
});
if (rederivedCount !== rederivedEntries.size) return null;
if (appended) {
entries.push(
...deriveTimelineEntriesFromVisibleTurnItems({
Expand Down
Loading