Skip to content

Commit b332698

Browse files
committed
perf(web): reuse timeline rows while text streams
Reuse ordered timeline entries and unchanged rows when a streaming message changes only its text or update time. Keep the full derivation for structural and control changes. Keep projections local to each timeline and preserve earlier emitted rows. Leave activity folding, tool labels, duration rules, shared presentation, and thread subscription state unchanged. Verified with 226 focused tests, the web typecheck, and same-process source parity and timing checks. Created with GPT-6 Astra (preview) in Codex.
1 parent f778480 commit b332698

6 files changed

Lines changed: 501 additions & 23 deletions

File tree

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,14 @@ import {
9696
derivePendingApprovals,
9797
derivePendingUserInputs,
9898
derivePhase,
99-
deriveTimelineEntries,
99+
deriveTimelineEntriesWithState,
100100
deriveActiveWorkStartedAt,
101101
deriveActivePlanState,
102102
findLatestProposedPlan,
103103
deriveWorkLogEntries,
104104
hasActionableProposedPlan,
105105
isLatestTurnSettled,
106+
type TimelineEntriesProjection,
106107
} from "../session-logic";
107108
import { type LegendListRef } from "@legendapp/list/react";
108109
import {
@@ -2939,11 +2940,27 @@ export default function ChatView(props: ChatViewProps) {
29392940
feedbackSubmissions,
29402941
optimisticUserMessages,
29412942
]);
2942-
const timelineEntries = useMemo(
2943-
() =>
2944-
deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
2945-
[activeThread?.proposedPlans, timelineMessages, workLogEntries],
2946-
);
2943+
const timelineProjectionRef = useRef<{
2944+
threadKey: string | null;
2945+
projection: TimelineEntriesProjection;
2946+
} | null>(null);
2947+
const timelineEntries = useMemo(() => {
2948+
const previous = timelineProjectionRef.current;
2949+
const projection = deriveTimelineEntriesWithState(
2950+
timelineMessages,
2951+
activeThread?.proposedPlans ?? [],
2952+
workLogEntries,
2953+
previous?.threadKey === activeThreadKey ? previous.projection : null,
2954+
);
2955+
timelineProjectionRef.current = { threadKey: activeThreadKey, projection };
2956+
return projection.entries;
2957+
}, [
2958+
timelineProjectionRef,
2959+
activeThreadKey,
2960+
activeThread?.proposedPlans,
2961+
timelineMessages,
2962+
workLogEntries,
2963+
]);
29472964
const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
29482965
const draftHeroDockRequested =
29492966
activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;

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

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
computeStableMessagesTimelineRows,
55
computeMessageDurationStart,
66
deriveMessagesTimelineRows,
7+
deriveMessagesTimelineRowsWithState,
78
liveWorkEntryLabel,
89
normalizeCompactToolLabel,
910
resolveAssistantMessageCopyState,
@@ -13,6 +14,260 @@ import {
1314
type MessagesTimelineRow,
1415
workEntryDisplayLabel,
1516
} from "./MessagesTimeline.logic";
17+
import {
18+
deriveTimelineEntries,
19+
deriveTimelineEntriesWithState,
20+
type WorkLogEntry,
21+
} from "../../session-logic";
22+
import type { ChatMessage, TurnDiffSummary } from "../../types";
23+
24+
describe("streaming row projection", () => {
25+
function fixture(text = "") {
26+
const turnId = TurnId.make("live-turn");
27+
const historyTurnId = TurnId.make("history-turn");
28+
const time = (second: number) => new Date(Date.UTC(2026, 8, 4, 0, 0, second)).toISOString();
29+
const messages: ChatMessage[] = [
30+
{
31+
id: MessageId.make("history-user"),
32+
role: "user",
33+
text: "Inspect",
34+
turnId: null,
35+
createdAt: time(0),
36+
updatedAt: time(0),
37+
streaming: false,
38+
},
39+
{
40+
id: MessageId.make("history-assistant"),
41+
role: "assistant",
42+
text: "Done",
43+
turnId: historyTurnId,
44+
createdAt: time(3),
45+
updatedAt: time(4),
46+
streaming: false,
47+
},
48+
{
49+
id: MessageId.make("live-user"),
50+
role: "user",
51+
text: "Continue",
52+
turnId: null,
53+
createdAt: time(5),
54+
updatedAt: time(5),
55+
streaming: false,
56+
},
57+
{
58+
id: MessageId.make("live-assistant"),
59+
role: "assistant",
60+
text,
61+
turnId,
62+
createdAt: time(7),
63+
updatedAt: time(7),
64+
streaming: true,
65+
},
66+
];
67+
const work: WorkLogEntry[] = [
68+
{
69+
id: "history-work",
70+
turnId: historyTurnId,
71+
createdAt: time(1),
72+
label: "Ran command",
73+
tone: "tool",
74+
command: "vp test",
75+
toolCallId: "history-tool",
76+
toolLifecycleStatus: "failed",
77+
sourceActivityKind: "tool.completed",
78+
},
79+
{
80+
id: "live-work",
81+
turnId,
82+
createdAt: time(6),
83+
label: "Read file",
84+
tone: "tool",
85+
changedFiles: ["/repo/src/index.ts"],
86+
toolCallId: "live-tool",
87+
toolLifecycleStatus: "completed",
88+
sourceActivityKind: "tool.completed",
89+
},
90+
];
91+
const timeline = deriveTimelineEntriesWithState(messages, [], work);
92+
const input = {
93+
timelineEntries: timeline.entries,
94+
latestTurn: { turnId, state: "running", startedAt: time(5), completedAt: null },
95+
runningTurnId: turnId,
96+
isWorking: true,
97+
activeTurnStartedAt: time(5),
98+
turnDiffSummaryByAssistantMessageId: new Map<MessageId, TurnDiffSummary>(),
99+
revertTurnCountByUserMessageId: new Map<MessageId, number>(),
100+
} satisfies Parameters<typeof deriveMessagesTimelineRows>[0];
101+
return { messages, work, timeline, input, time, turnId, historyTurnId };
102+
}
103+
104+
it.each([
105+
["", "Now visible"],
106+
[" \n", "Now visible"],
107+
["Visible", ""],
108+
["", " \t\n"],
109+
["Visible", "★ Insight\nKeep these line breaks"],
110+
])("keeps row parity and history identity when text changes from %j to %j", (before, after) => {
111+
const initial = fixture(before);
112+
const previous = deriveMessagesTimelineRowsWithState(initial.input);
113+
Object.freeze(previous.rows);
114+
for (const row of previous.rows) Object.freeze(row);
115+
const last = initial.messages.at(-1)!;
116+
const messages = [
117+
...initial.messages.slice(0, -1),
118+
{ ...last, text: after, updatedAt: initial.time(8) },
119+
];
120+
const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline);
121+
const input = { ...initial.input, timelineEntries: timeline.entries };
122+
const next = deriveMessagesTimelineRowsWithState(input, previous);
123+
124+
expect(next.rows).toEqual(
125+
deriveMessagesTimelineRows({
126+
...input,
127+
timelineEntries: deriveTimelineEntries(messages, [], initial.work),
128+
}),
129+
);
130+
for (const [index, row] of previous.rows.entries()) {
131+
if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) {
132+
expect(next.rows[index]).toMatchObject({ message: { text: after } });
133+
expect(row.message.text).toBe(before);
134+
} else {
135+
expect(next.rows[index]).toBe(row);
136+
}
137+
}
138+
});
139+
140+
it("keeps earlier emitted rows unchanged across two projection branches", () => {
141+
const initial = fixture();
142+
const previous = deriveMessagesTimelineRowsWithState(initial.input);
143+
const last = initial.messages.at(-1)!;
144+
const branch = (text: string) => {
145+
const messages = [...initial.messages.slice(0, -1), { ...last, text }];
146+
const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline);
147+
const input = { ...initial.input, timelineEntries: timeline.entries };
148+
const projection = deriveMessagesTimelineRowsWithState(input, previous);
149+
expect(projection.rows).toEqual(deriveMessagesTimelineRows(input));
150+
return projection;
151+
};
152+
const first = branch("First branch");
153+
const savedFirst = structuredClone(first.rows);
154+
branch("Second branch");
155+
expect(first.rows).toEqual(savedFirst);
156+
expect(previous.rows).toEqual(deriveMessagesTimelineRows(initial.input));
157+
});
158+
159+
it.each(["completion", "turn", "role", "ordering"] as const)(
160+
"rebuilds row structure for a %s change with otherwise unchanged controls",
161+
(change) => {
162+
const initial = fixture("Partial");
163+
const input = {
164+
...initial.input,
165+
latestTurn: {
166+
...initial.input.latestTurn,
167+
state: "completed" as const,
168+
completedAt: initial.time(9),
169+
},
170+
runningTurnId: null,
171+
isWorking: false,
172+
};
173+
const previous = deriveMessagesTimelineRowsWithState(input);
174+
const last = initial.messages.at(-1)!;
175+
const changed: ChatMessage =
176+
change === "completion"
177+
? { ...last, streaming: false, updatedAt: initial.time(10) }
178+
: change === "turn"
179+
? { ...last, turnId: initial.historyTurnId }
180+
: change === "role"
181+
? { ...last, role: "user", turnId: null }
182+
: { ...last, createdAt: initial.time(0) };
183+
const messages = [...initial.messages.slice(0, -1), changed];
184+
const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline);
185+
const nextInput = { ...input, timelineEntries: timeline.entries };
186+
const next = deriveMessagesTimelineRowsWithState(nextInput, previous);
187+
expect(next.rows).toEqual(
188+
deriveMessagesTimelineRows({
189+
...nextInput,
190+
timelineEntries: deriveTimelineEntries(messages, [], initial.work),
191+
}),
192+
);
193+
expect(previous.rows).toEqual(deriveMessagesTimelineRows(input));
194+
},
195+
);
196+
197+
it("keeps full parity through completion, trailing failure, metadata, expansion, and paging", () => {
198+
const initial = fixture("Partial");
199+
let timeline = initial.timeline;
200+
let input: Parameters<typeof deriveMessagesTimelineRows>[0] = initial.input;
201+
let projection = deriveMessagesTimelineRowsWithState(input);
202+
let messages = initial.messages;
203+
let work = initial.work;
204+
const check = (changes: Partial<typeof input> = {}) => {
205+
const oldRows = structuredClone(projection.rows);
206+
const previous = projection;
207+
timeline = deriveTimelineEntriesWithState(messages, [], work, timeline);
208+
input = { ...input, ...changes, timelineEntries: timeline.entries };
209+
projection = deriveMessagesTimelineRowsWithState(input, previous);
210+
expect(projection.rows).toEqual(
211+
deriveMessagesTimelineRows({
212+
...input,
213+
timelineEntries: deriveTimelineEntries(messages, [], work),
214+
}),
215+
);
216+
expect(previous.rows).toEqual(oldRows);
217+
};
218+
219+
messages = [
220+
...messages.slice(0, -1),
221+
{ ...messages.at(-1)!, streaming: false, text: "Complete", updatedAt: initial.time(9) },
222+
];
223+
check({
224+
latestTurn: { ...initial.input.latestTurn, state: "completed", completedAt: initial.time(9) },
225+
runningTurnId: null,
226+
isWorking: false,
227+
});
228+
work = [
229+
...work,
230+
{
231+
id: "late-failure",
232+
turnId: initial.turnId,
233+
createdAt: initial.time(10),
234+
label: "Failed command",
235+
command: "vp test",
236+
tone: "error",
237+
toolCallId: "failed-tool",
238+
toolLifecycleStatus: "failed",
239+
sourceActivityKind: "tool.completed",
240+
},
241+
];
242+
check();
243+
messages = [
244+
...messages.slice(0, -1),
245+
{ ...messages.at(-1)!, text: "Updated final text", updatedAt: initial.time(11) },
246+
];
247+
check();
248+
check({
249+
latestTurn: {
250+
...initial.input.latestTurn,
251+
state: "interrupted",
252+
completedAt: initial.time(12),
253+
},
254+
});
255+
check({ expandedTurnIds: new Set([initial.historyTurnId, initial.turnId]) });
256+
const group = projection.rows.find((row) => row.kind === "work-toggle");
257+
check({ expandedWorkGroupIds: new Set(group ? [group.id] : []) });
258+
messages = [
259+
{ ...messages[0]!, id: MessageId.make("older-user"), createdAt: "2026-09-03T23:59:00.000Z" },
260+
...messages,
261+
];
262+
check();
263+
messages = messages.map((message) =>
264+
message.id === MessageId.make("live-assistant")
265+
? { ...message, role: "user", turnId: null, createdAt: initial.time(0) }
266+
: message,
267+
);
268+
check({ revertTurnCountByUserMessageId: new Map([[MessageId.make("live-user"), 3]]) });
269+
});
270+
});
16271

17272
describe("expanded tool group scrolling", () => {
18273
const entries = [{ id: "first" }, { id: "second" }];

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as Equal from "effect/Equal";
2+
import { shallow } from "zustand/vanilla/shallow";
23
import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
34
import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
45
import {
@@ -19,6 +20,7 @@ export {
1920
} from "@t3tools/client-runtime/work-log/presentation";
2021
import {
2122
formatDuration,
23+
isStreamingMessageTextUpdate,
2224
workEntryDisplayIndicatesToolFailure,
2325
workEntryIndicatesToolSuccess,
2426
workEntryIndicatesToolNeutralStatus,
@@ -1153,6 +1155,59 @@ export function deriveMessagesTimelineRows(input: {
11531155
return attachTrailingToolGroupsToAssistant(nextRows);
11541156
}
11551157

1158+
type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0];
1159+
1160+
export interface MessagesTimelineRowsProjection {
1161+
readonly input: MessagesTimelineRowsInput;
1162+
readonly rows: MessagesTimelineRow[];
1163+
}
1164+
1165+
function replaceStreamingMessageRows(
1166+
input: MessagesTimelineRowsInput,
1167+
previous: MessagesTimelineRowsProjection,
1168+
): MessagesTimelineRow[] | null {
1169+
const { timelineEntries: previousEntries, ...previousContext } = previous.input;
1170+
const { timelineEntries, ...context } = input;
1171+
if (timelineEntries.length !== previousEntries.length || !shallow(previousContext, context)) {
1172+
return null;
1173+
}
1174+
const replacements = new Map<ChatMessage, ChatMessage>();
1175+
for (const [index, entry] of timelineEntries.entries()) {
1176+
const previousEntry = previousEntries[index]!;
1177+
if (entry === previousEntry) continue;
1178+
if (
1179+
entry.kind !== "message" ||
1180+
previousEntry.kind !== "message" ||
1181+
entry.id !== previousEntry.id ||
1182+
entry.createdAt !== previousEntry.createdAt
1183+
) {
1184+
return null;
1185+
}
1186+
if (entry.message === previousEntry.message) continue;
1187+
if (!isStreamingMessageTextUpdate(previousEntry.message, entry.message)) return null;
1188+
replacements.set(previousEntry.message, entry.message);
1189+
}
1190+
if (replacements.size === 0) return previous.rows;
1191+
return previous.rows.map((row) => {
1192+
if (row.kind !== "message" && row.kind !== "assistant-meta") return row;
1193+
const message = replacements.get(row.message);
1194+
return message ? { ...row, message } : row;
1195+
});
1196+
}
1197+
1198+
/** Keep one projection per timeline. Reuse rows only when streaming content changes. */
1199+
export function deriveMessagesTimelineRowsWithState(
1200+
input: MessagesTimelineRowsInput,
1201+
previous: MessagesTimelineRowsProjection | null = null,
1202+
): MessagesTimelineRowsProjection {
1203+
return {
1204+
input,
1205+
rows:
1206+
(previous === null ? null : replaceStreamingMessageRows(input, previous)) ??
1207+
deriveMessagesTimelineRows(input),
1208+
};
1209+
}
1210+
11561211
export function computeStableMessagesTimelineRows(
11571212
rows: MessagesTimelineRow[],
11581213
previous: StableMessagesTimelineRowsState,

0 commit comments

Comments
 (0)