Skip to content

Commit 2e547ee

Browse files
Batuhan1999claude
andcommitted
perf(web): streaming thinking and plan text update only their own timeline row
#26727 re-derives a changed reasoning or plan entry in place, but two paths still rebuild everything while those stream: - Each proposed-plan delta rebuilds the plan artifacts, so the entry reuse check (shallow plans) fails. Entries read only each plan's id, kind and status, so compare those instead. - Row derivation only reused rows for assistant message text. Patch the live work row holding streamed thinking, and the proposed-plan row, in place. Settled groups, folded turns and toggles summarize thinking text, so they keep the full rebuild. isStreamingTurnItemTextUpdate now accepts reasoning and proposed-plan items for the row check; entry reuse keeps patching only assistant text and leaves the rest to the in-place re-derive. Fixes #24120 Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 8c9af54 commit 2e547ee

5 files changed

Lines changed: 497 additions & 32 deletions

File tree

‎apps/web/src/components/chat/MessagesTimeline.logic.test.ts‎

Lines changed: 218 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import {
1818
workEntryDisplayIndicatesToolFailure,
1919
type TimelineEntry,
2020
} from "../../session-logic";
21-
import { makeStreamingTimelineFixture } from "../../test-fixtures";
21+
import {
22+
makeStreamingTimelineFixture,
23+
makeStreamingWorkTimelineFixture,
24+
withStreamedText,
25+
} from "../../test-fixtures";
2226
import type { TurnDiffSummary } from "../../types";
2327
import { describe, expect, it } from "vite-plus/test";
2428
import { EnvironmentId, MessageId, RunId } from "@t3tools/contracts";
@@ -3817,6 +3821,219 @@ describe("streaming v2 row projection", () => {
38173821
})),
38183822
});
38193823
});
3824+
3825+
it.each(["reasoning", "proposed_plan"] as const)(
3826+
"keeps every row not holding streaming %s text when it changes",
3827+
(kind) => {
3828+
const source = makeStreamingWorkTimelineFixture(kind);
3829+
const rowsInput = (timelineEntries: ReadonlyArray<TimelineEntry>) => ({
3830+
timelineEntries,
3831+
latestRun: {
3832+
runId: source.runId,
3833+
status: "running" as const,
3834+
startedAt: source.time(5),
3835+
completedAt: null,
3836+
},
3837+
runningRunId: source.runId,
3838+
isWorking: true,
3839+
activeTurnStartedAt: source.time(5),
3840+
turnDiffSummaries: [],
3841+
supportsConversationRollback: true,
3842+
});
3843+
let timelineInput = {
3844+
visibleTurnItems: withStreamedText(
3845+
source.visibleTurnItems,
3846+
source.streamingItemId,
3847+
"Let",
3848+
source.time(20),
3849+
),
3850+
optimisticMessages: [],
3851+
};
3852+
let timeline = deriveTimelineEntriesFromVisibleTurnItemsWithState(timelineInput);
3853+
let projection = deriveMessagesTimelineRowsWithState(rowsInput(timeline.entries));
3854+
for (const [step, text] of ["Let me", "Let me check the tests"].entries()) {
3855+
timelineInput = {
3856+
visibleTurnItems: withStreamedText(
3857+
timelineInput.visibleTurnItems,
3858+
source.streamingItemId,
3859+
text,
3860+
source.time(21 + step),
3861+
),
3862+
optimisticMessages: [],
3863+
};
3864+
const previous = projection;
3865+
timeline = deriveTimelineEntriesFromVisibleTurnItemsWithState(timelineInput, timeline);
3866+
projection = deriveMessagesTimelineRowsWithState(rowsInput(timeline.entries), previous);
3867+
expect(projection.rows).toEqual(
3868+
deriveMessagesTimelineRows(
3869+
rowsInput(deriveTimelineEntriesFromVisibleTurnItems(timelineInput)),
3870+
),
3871+
);
3872+
expect(projection.rows).toHaveLength(previous.rows.length);
3873+
for (const [index, row] of previous.rows.entries()) {
3874+
const holdsItem =
3875+
row.id === source.streamingItemId ||
3876+
((row.kind === "work" || row.kind === "work-live") &&
3877+
row.groupedEntries.some((entry) => entry.id === source.streamingItemId));
3878+
if (!holdsItem) expect(projection.rows[index]).toBe(row);
3879+
}
3880+
}
3881+
},
3882+
);
3883+
3884+
it.each(["reasoning", "proposed_plan"] as const)(
3885+
"matches a full derivation when streaming %s text turns blank or completes",
3886+
(kind) => {
3887+
const source = makeStreamingWorkTimelineFixture(kind);
3888+
const rowsInput = (timelineEntries: ReadonlyArray<TimelineEntry>) => ({
3889+
timelineEntries,
3890+
latestRun: {
3891+
runId: source.runId,
3892+
status: "running" as const,
3893+
startedAt: source.time(5),
3894+
completedAt: null,
3895+
},
3896+
runningRunId: source.runId,
3897+
isWorking: true,
3898+
activeTurnStartedAt: source.time(5),
3899+
turnDiffSummaries: [],
3900+
supportsConversationRollback: true,
3901+
});
3902+
let visibleTurnItems = source.visibleTurnItems;
3903+
let timeline = deriveTimelineEntriesFromVisibleTurnItemsWithState({
3904+
visibleTurnItems,
3905+
optimisticMessages: [],
3906+
});
3907+
let projection = deriveMessagesTimelineRowsWithState(rowsInput(timeline.entries));
3908+
const steps = [
3909+
...["Let", " ", "Let me"].map(
3910+
(text, step) => (rows: typeof visibleTurnItems) =>
3911+
withStreamedText(rows, source.streamingItemId, text, source.time(20 + step)),
3912+
),
3913+
(rows: typeof visibleTurnItems) =>
3914+
rows.map((row) =>
3915+
row.item.id === source.streamingItemId &&
3916+
(row.item.type === "reasoning" || row.item.type === "proposed_plan")
3917+
? {
3918+
...row,
3919+
item: {
3920+
...row.item,
3921+
status: "completed" as const,
3922+
streaming: false,
3923+
completedAt: DateTime.makeUnsafe(source.time(30)),
3924+
updatedAt: DateTime.makeUnsafe(source.time(30)),
3925+
},
3926+
}
3927+
: row,
3928+
),
3929+
];
3930+
for (const step of steps) {
3931+
visibleTurnItems = step(visibleTurnItems);
3932+
const timelineInput = { visibleTurnItems, optimisticMessages: [] };
3933+
timeline = deriveTimelineEntriesFromVisibleTurnItemsWithState(timelineInput, timeline);
3934+
expect(timeline.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(timelineInput));
3935+
projection = deriveMessagesTimelineRowsWithState(rowsInput(timeline.entries), projection);
3936+
expect(projection.rows).toEqual(
3937+
deriveMessagesTimelineRows(
3938+
rowsInput(deriveTimelineEntriesFromVisibleTurnItems(timelineInput)),
3939+
),
3940+
);
3941+
}
3942+
},
3943+
);
3944+
3945+
it.each([
3946+
"expanded live group",
3947+
"thinking superseded by a running tool",
3948+
"settled turn",
3949+
] as const)("matches a full derivation when streaming thinking sits in a %s", (scenario) => {
3950+
const source = makeStreamingWorkTimelineFixture("reasoning");
3951+
const running = scenario !== "settled turn";
3952+
const rowsInput = (
3953+
timelineEntries: ReadonlyArray<TimelineEntry>,
3954+
expandedWorkGroupIds?: ReadonlySet<string>,
3955+
) => ({
3956+
timelineEntries,
3957+
latestRun: {
3958+
runId: source.runId,
3959+
status: running ? ("running" as const) : ("completed" as const),
3960+
startedAt: source.time(5),
3961+
completedAt: running ? null : source.time(40),
3962+
},
3963+
runningRunId: running ? source.runId : null,
3964+
isWorking: running,
3965+
activeTurnStartedAt: source.time(5),
3966+
turnDiffSummaries: [],
3967+
supportsConversationRollback: true,
3968+
...(expandedWorkGroupIds ? { expandedWorkGroupIds } : {}),
3969+
...(running ? {} : { expandedRunIds: new Set([source.runId]) }),
3970+
});
3971+
const tool = source.visibleTurnItems.find((row) => row.item.id === "live-work")!;
3972+
let visibleTurnItems =
3973+
scenario === "thinking superseded by a running tool"
3974+
? [
3975+
...source.visibleTurnItems,
3976+
{
3977+
...tool,
3978+
position: source.visibleTurnItems.length,
3979+
sourceItemId: TurnItemId.make("live-running-tool"),
3980+
item: {
3981+
...tool.item,
3982+
id: TurnItemId.make("live-running-tool"),
3983+
ordinal: 8,
3984+
status: "running" as const,
3985+
completedAt: null,
3986+
},
3987+
},
3988+
]
3989+
: // Alone in a settled group, the thought's text becomes the group summary.
3990+
source.visibleTurnItems.filter((row) => running || row !== tool);
3991+
visibleTurnItems = withStreamedText(
3992+
visibleTurnItems,
3993+
source.streamingItemId,
3994+
"Let",
3995+
source.time(20),
3996+
);
3997+
let timeline = deriveTimelineEntriesFromVisibleTurnItemsWithState({
3998+
visibleTurnItems,
3999+
optimisticMessages: [],
4000+
});
4001+
const liveRow = deriveMessagesTimelineRows(rowsInput(timeline.entries)).find(
4002+
(row) => row.kind === "work-live",
4003+
);
4004+
const expanded =
4005+
scenario === "expanded live group" && liveRow?.kind === "work-live"
4006+
? new Set([liveRow.groupId])
4007+
: undefined;
4008+
let projection = deriveMessagesTimelineRowsWithState(rowsInput(timeline.entries, expanded));
4009+
for (const [step, text] of ["Let me", "Let me check the tests"].entries()) {
4010+
visibleTurnItems = withStreamedText(
4011+
visibleTurnItems,
4012+
source.streamingItemId,
4013+
text,
4014+
source.time(21 + step),
4015+
);
4016+
const timelineInput = { visibleTurnItems, optimisticMessages: [] };
4017+
const previous = projection;
4018+
timeline = deriveTimelineEntriesFromVisibleTurnItemsWithState(timelineInput, timeline);
4019+
projection = deriveMessagesTimelineRowsWithState(
4020+
rowsInput(timeline.entries, expanded),
4021+
previous,
4022+
);
4023+
expect(projection.rows).toEqual(
4024+
deriveMessagesTimelineRows(
4025+
rowsInput(deriveTimelineEntriesFromVisibleTurnItems(timelineInput), expanded),
4026+
),
4027+
);
4028+
if (!running) continue;
4029+
for (const [index, row] of previous.rows.entries()) {
4030+
const holdsItem =
4031+
(row.kind === "work" || row.kind === "work-live") &&
4032+
row.groupedEntries.some((entry) => entry.id === source.streamingItemId);
4033+
if (!holdsItem) expect(projection.rows[index]).toBe(row);
4034+
}
4035+
}
4036+
});
38204037
});
38214038

38224039
describe("linked timeline resources", () => {

‎apps/web/src/components/chat/MessagesTimeline.logic.ts‎

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,25 +1357,29 @@ function withoutSubagentDelegationRows(entries: ReadonlyArray<TimelineEntry>) {
13571357
});
13581358
}
13591359

1360-
const supersededReasoningEntries = new WeakMap<TimelineEntry, TimelineEntry>();
1360+
type WorkTimelineEntry = Extract<TimelineEntry, { kind: "work" }>;
1361+
const supersededReasoningEntries = new WeakMap<WorkTimelineEntry, WorkTimelineEntry>();
1362+
1363+
function settleReasoning(entry: WorkTimelineEntry, superseded: boolean): WorkTimelineEntry {
1364+
if (
1365+
!superseded ||
1366+
entry.entry.itemType !== "reasoning" ||
1367+
entry.entry.toolLifecycleStatus !== "inProgress"
1368+
) {
1369+
return entry;
1370+
}
1371+
let settled = supersededReasoningEntries.get(entry);
1372+
if (!settled) {
1373+
settled = { ...entry, entry: { ...entry.entry, toolLifecycleStatus: "completed" } };
1374+
supersededReasoningEntries.set(entry, settled);
1375+
}
1376+
return settled;
1377+
}
13611378

13621379
function settleSupersededReasoning(entries: ReadonlyArray<TimelineEntry>) {
1363-
return entries.map((entry, index) => {
1364-
if (
1365-
index === entries.length - 1 ||
1366-
entry.kind !== "work" ||
1367-
entry.entry.itemType !== "reasoning" ||
1368-
entry.entry.toolLifecycleStatus !== "inProgress"
1369-
) {
1370-
return entry;
1371-
}
1372-
let settled = supersededReasoningEntries.get(entry);
1373-
if (!settled) {
1374-
settled = { ...entry, entry: { ...entry.entry, toolLifecycleStatus: "completed" } };
1375-
supersededReasoningEntries.set(entry, settled);
1376-
}
1377-
return settled;
1378-
});
1380+
return entries.map((entry, index) =>
1381+
entry.kind === "work" ? settleReasoning(entry, index < entries.length - 1) : entry,
1382+
);
13791383
}
13801384

13811385
function workShownAsFinalAnswerOnly(
@@ -2473,19 +2477,33 @@ function replaceStreamingMessageRows(
24732477
return null;
24742478
}
24752479
const replacements = new Map<ChatMessage, Extract<TimelineEntry, { kind: "message" }>>();
2480+
const thoughts = new Map<string, WorkLogEntry>();
2481+
const plans = new Map<string, ProposedPlan>();
24762482
for (let index = 0; index < timelineEntries.length; index += 1) {
24772483
const entry = timelineEntries[index]!;
24782484
const previousEntry = previousEntries[index]!;
24792485
if (entry === previousEntry) continue;
24802486
if (
2481-
entry.kind !== "message" ||
2482-
previousEntry.kind !== "message" ||
2487+
entry.kind !== previousEntry.kind ||
24832488
entry.id !== previousEntry.id ||
24842489
entry.createdAt !== previousEntry.createdAt ||
24852490
entry.attempt !== previousEntry.attempt
24862491
) {
24872492
return null;
24882493
}
2494+
if (entry.kind === "work" && previousEntry.kind === "work") {
2495+
if (!isStreamingThoughtUpdate(previousEntry.entry, entry.entry)) return null;
2496+
thoughts.set(entry.id, settleReasoning(entry, index < timelineEntries.length - 1).entry);
2497+
continue;
2498+
}
2499+
if (entry.kind === "proposed-plan" && previousEntry.kind === "proposed-plan") {
2500+
const { planMarkdown: _before, updatedAt: _beforeAt, ...before } = previousEntry.proposedPlan;
2501+
const { planMarkdown: _after, updatedAt: _afterAt, ...after } = entry.proposedPlan;
2502+
if (!shallow(before, after)) return null;
2503+
plans.set(entry.id, entry.proposedPlan);
2504+
continue;
2505+
}
2506+
if (entry.kind !== "message" || previousEntry.kind !== "message") return null;
24892507
if (
24902508
entry.projectedItem !== previousEntry.projectedItem &&
24912509
(entry.projectedItem === undefined ||
@@ -2502,8 +2520,38 @@ function replaceStreamingMessageRows(
25022520
if (!isStreamingMessageTextUpdate(previousEntry.message, entry.message)) return null;
25032521
replacements.set(previousEntry.message, entry);
25042522
}
2505-
if (replacements.size === 0) return previous.rows;
2506-
return previous.rows.map((row) => {
2523+
if (replacements.size === 0 && thoughts.size === 0 && plans.size === 0) return previous.rows;
2524+
const patchedGroups = new Map<WorkLogEntry[], WorkLogEntry[]>();
2525+
const patchGroup = (group: WorkLogEntry[]) => {
2526+
if (!group.some((entry) => thoughts.has(entry.id))) return group;
2527+
const patched =
2528+
patchedGroups.get(group) ?? group.map((entry) => thoughts.get(entry.id) ?? entry);
2529+
patchedGroups.set(group, patched);
2530+
return patched;
2531+
};
2532+
const patchedLiveThoughtIds = new Set<string>();
2533+
const rows = previous.rows.map((row): MessagesTimelineRow => {
2534+
if (row.kind === "proposed-plan") {
2535+
const proposedPlan = plans.get(row.id);
2536+
return proposedPlan ? { ...row, proposedPlan } : row;
2537+
}
2538+
if (row.kind === "work-live") {
2539+
const groupedEntries = patchGroup(row.groupedEntries);
2540+
if (groupedEntries === row.groupedEntries) return row;
2541+
for (const entry of groupedEntries) {
2542+
if (thoughts.has(entry.id)) patchedLiveThoughtIds.add(entry.id);
2543+
}
2544+
return {
2545+
...row,
2546+
entry: thoughts.get(row.entry.id) ?? row.entry,
2547+
groupedEntries,
2548+
...(row.thought ? { thought: thoughts.get(row.thought.id) ?? row.thought } : {}),
2549+
};
2550+
}
2551+
if (row.kind === "work") {
2552+
const groupedEntries = patchGroup(row.groupedEntries);
2553+
return groupedEntries === row.groupedEntries ? row : { ...row, groupedEntries };
2554+
}
25072555
if (row.kind !== "message" && row.kind !== "assistant-meta") return row;
25082556
const entry = replacements.get(row.message);
25092557
return entry
@@ -2514,6 +2562,39 @@ function replaceStreamingMessageRows(
25142562
}
25152563
: row;
25162564
});
2565+
// Only a live row holds thinking without deriving a label from its text. Settled
2566+
// groups, folded turns, and work toggles summarize it, so they need the full rebuild.
2567+
for (const id of thoughts.keys()) if (!patchedLiveThoughtIds.has(id)) return null;
2568+
return rows;
2569+
}
2570+
2571+
/**
2572+
* Rows read a thought's text only through its blankness (visibility, the live
2573+
* thought line) and through labels that live rows do not store.
2574+
*/
2575+
function isStreamingThoughtUpdate(previous: WorkLogEntry, next: WorkLogEntry): boolean {
2576+
if (
2577+
previous.itemType !== "reasoning" ||
2578+
previous.projectedItem === undefined ||
2579+
next.projectedItem === undefined ||
2580+
!isStreamingTurnItemTextUpdate(previous.projectedItem, next.projectedItem) ||
2581+
Boolean(previous.detail?.trim()) !== Boolean(next.detail?.trim())
2582+
) {
2583+
return false;
2584+
}
2585+
const {
2586+
detail: _before,
2587+
structuredPayload: _beforeItem,
2588+
projectedItem: _beforeRow,
2589+
...rest
2590+
} = previous;
2591+
const {
2592+
detail: _after,
2593+
structuredPayload: _afterItem,
2594+
projectedItem: _afterRow,
2595+
...nextRest
2596+
} = next;
2597+
return shallow(rest, nextRest);
25172598
}
25182599

25192600
export function deriveMessagesTimelineRowsWithState(

0 commit comments

Comments
 (0)