authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 10:56:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 10:56:55-07:00
log19c1710a88a2c87c159d76269dacdf0b17ddd9f4
tree4ffe8e2c34b1bc26a621e51054ac25a42ad93f33
parentc7bf3115f2223ede5cb2316613dc7369155dbeb1
signature Signed by PGP key B5690EEEBB952194

perf(web): reuse timeline rows while text streams (#9725)

Reuse ordered timeline entries and raw rows for safe streaming text updates. Keep full derivation for structural, metadata, activity, and control changes. Preserve immutable attachment-preview objects when their current URLs are unchanged. Cover URL renewal/removal, completion, grouping, pagination, and earlier-row immutability. Continue extoci's ordered timeline projection work with separate integration and attachment corrections. The original contribution remains unchanged. Created with GPT-6 Astra (preview) in Codex. Co-Authored-By: extoci <hi@extoci.lol>

6 files changed, 874 insertions(+), 70 deletions(-)

apps/web/src/components/ChatView.tsx+37-36
...@@ -93,16 +93,18 @@ import {...@@ -93,16 +93,18 @@ import {
93 parseStandaloneComposerSlashCommand,93 parseStandaloneComposerSlashCommand,
94} from "../composer-logic";94} from "../composer-logic";
95import {95import {
96 createMessageAttachmentPreviewProjector,
96 derivePendingApprovals,97 derivePendingApprovals,
97 derivePendingUserInputs,98 derivePendingUserInputs,
98 derivePhase,99 derivePhase,
99 deriveTimelineEntries,100 deriveTimelineEntriesWithState,
100 deriveActiveWorkStartedAt,101 deriveActiveWorkStartedAt,
101 deriveActivePlanState,102 deriveActivePlanState,
102 findLatestProposedPlan,103 findLatestProposedPlan,
103 deriveWorkLogEntries,104 deriveWorkLogEntries,
104 hasActionableProposedPlan,105 hasActionableProposedPlan,
105 isLatestTurnSettled,106 isLatestTurnSettled,
107 type TimelineEntriesProjection,
106} from "../session-logic";108} from "../session-logic";
107import { type LegendListRef } from "@legendapp/list/react";109import { type LegendListRef } from "@legendapp/list/react";
108import {110import {
...@@ -2706,6 +2708,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -2706,6 +2708,8 @@ export default function ChatView(props: ChatViewProps) {
2706 });2708 });
2707 }, []);2709 }, []);
2708 const serverMessages = activeThread?.messages;2710 const serverMessages = activeThread?.messages;
2711 const [projectServerMessagePreviews] = useState(createMessageAttachmentPreviewProjector);
2712 const [projectHandoffMessagePreviews] = useState(createMessageAttachmentPreviewProjector);
2709 const downloadFileAttachment = useCallback(2713 const downloadFileAttachment = useCallback(
2710 async (attachment: ChatFileAttachment) => {2714 async (attachment: ChatFileAttachment) => {
2711 const connection = readPreparedConnection(environmentId);2715 const connection = readPreparedConnection(environmentId);
...@@ -2777,19 +2781,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -2777,19 +2781,12 @@ export default function ChatView(props: ChatViewProps) {
2777 );2781 );
2778 const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => {2782 const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => {
2779 if (!serverMessages) return [];2783 if (!serverMessages) return [];
2780 return serverMessages.map((message) => {2784 return serverMessages.map((message) =>
2781 if (!message.attachments || message.attachments.length === 0) {2785 projectServerMessagePreviews(message, (attachment) =>
2782 return message;2786 serverAttachmentUrlById.get(attachment.id),
2783 }2787 ),
2784 return {2788 );
2785 ...message,2789 }, [projectServerMessagePreviews, serverAttachmentUrlById, serverMessages]);
2786 attachments: message.attachments.map((attachment) => {
2787 const previewUrl = serverAttachmentUrlById.get(attachment.id);
2788 return previewUrl ? { ...attachment, previewUrl } : attachment;
2789 }),
2790 };
2791 });
2792 }, [serverAttachmentUrlById, serverMessages]);
2793 useEffect(() => {2790 useEffect(() => {
2794 if (typeof Image === "undefined" || displayServerMessages.length === 0) {2791 if (typeof Image === "undefined" || displayServerMessages.length === 0) {
2795 return;2792 return;
...@@ -2879,10 +2876,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2879,10 +2876,7 @@ export default function ChatView(props: ChatViewProps) {
2879 const serverMessagesWithPreviewHandoff =2876 const serverMessagesWithPreviewHandoff =
2880 Object.keys(attachmentPreviewHandoffByMessageId).length === 02877 Object.keys(attachmentPreviewHandoffByMessageId).length === 0
2881 ? messages2878 ? messages
2882 : // Spread only fires for the few messages that actually changed;2879 : messages.map((message) => {
2883 // unchanged ones early-return their original reference.
2884 // In-place mutation would break React's immutable state contract.
2885 messages.map((message) => {
2886 if (2880 if (
2887 message.role !== "user" ||2881 message.role !== "user" ||
2888 !message.attachments ||2882 !message.attachments ||
...@@ -2895,25 +2889,15 @@ export default function ChatView(props: ChatViewProps) {...@@ -2895,25 +2889,15 @@ export default function ChatView(props: ChatViewProps) {
2895 return message;2889 return message;
2896 }2890 }
28972891
2898 let changed = false;
2899 let imageIndex = 0;2892 let imageIndex = 0;
2900 const attachments = message.attachments.map((attachment) => {2893 return projectHandoffMessagePreviews(message, (attachment) => {
2901 if (!isImageAttachment(attachment)) {2894 if (!isImageAttachment(attachment)) {
2902 return attachment;2895 return undefined;
2903 }2896 }
2904 const handoffPreviewUrl = handoffPreviewUrls[imageIndex];2897 const handoffPreviewUrl = handoffPreviewUrls[imageIndex];
2905 imageIndex += 1;2898 imageIndex += 1;
2906 if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) {2899 return handoffPreviewUrl;
2907 return attachment;
2908 }
2909 changed = true;
2910 return {
2911 ...attachment,
2912 previewUrl: handoffPreviewUrl,
2913 };
2914 });2900 });
2915
2916 return changed ? { ...message, attachments } : message;
2917 });2901 });
29182902
2919 const localMessages = [2903 const localMessages = [
...@@ -2938,12 +2922,29 @@ export default function ChatView(props: ChatViewProps) {...@@ -2938,12 +2922,29 @@ export default function ChatView(props: ChatViewProps) {
2938 displayServerMessages,2922 displayServerMessages,
2939 feedbackSubmissions,2923 feedbackSubmissions,
2940 optimisticUserMessages,2924 optimisticUserMessages,
2925 projectHandoffMessagePreviews,
2926 ]);
2927 const timelineProjectionRef = useRef<{
2928 threadKey: string | null;
2929 projection: TimelineEntriesProjection;
2930 } | null>(null);
2931 const timelineEntries = useMemo(() => {
2932 const previous = timelineProjectionRef.current;
2933 const projection = deriveTimelineEntriesWithState(
2934 timelineMessages,
2935 activeThread?.proposedPlans ?? [],
2936 workLogEntries,
2937 previous?.threadKey === activeThreadKey ? previous.projection : null,
2938 );
2939 timelineProjectionRef.current = { threadKey: activeThreadKey, projection };
2940 return projection.entries;
2941 }, [
2942 timelineProjectionRef,
2943 activeThreadKey,
2944 activeThread?.proposedPlans,
2945 timelineMessages,
2946 workLogEntries,
2941 ]);2947 ]);
2942 const timelineEntries = useMemo(
2943 () =>
2944 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
2945 [activeThread?.proposedPlans, timelineMessages, workLogEntries],
2946 );
2947 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);2948 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
2948 const draftHeroDockRequested =2949 const draftHeroDockRequested =
2949 activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;2950 activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+341
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 computeStableMessagesTimelineRows,4 computeStableMessagesTimelineRows,
5 computeMessageDurationStart,5 computeMessageDurationStart,
6 deriveMessagesTimelineRows,6 deriveMessagesTimelineRows,
7 deriveMessagesTimelineRowsWithState,
7 liveWorkEntryLabel,8 liveWorkEntryLabel,
8 normalizeCompactToolLabel,9 normalizeCompactToolLabel,
9 resolveAssistantMessageCopyState,10 resolveAssistantMessageCopyState,
...@@ -13,6 +14,346 @@ import {...@@ -13,6 +14,346 @@ import {
13 type MessagesTimelineRow,14 type MessagesTimelineRow,
14 workEntryDisplayLabel,15 workEntryDisplayLabel,
15} from "./MessagesTimeline.logic";16} from "./MessagesTimeline.logic";
17import {
18 createMessageAttachmentPreviewProjector,
19 deriveTimelineEntries,
20 deriveTimelineEntriesWithState,
21 type WorkLogEntry,
22} from "../../session-logic";
23import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types";
24
25describe("streaming row projection", () => {
26 function fixture(text = "") {
27 const turnId = TurnId.make("live-turn");
28 const historyTurnId = TurnId.make("history-turn");
29 const time = (second: number) => new Date(Date.UTC(2026, 8, 4, 0, 0, second)).toISOString();
30 const messages: ChatMessage[] = [
31 {
32 id: MessageId.make("history-user"),
33 role: "user",
34 text: "Inspect",
35 turnId: null,
36 createdAt: time(0),
37 updatedAt: time(0),
38 streaming: false,
39 },
40 {
41 id: MessageId.make("history-assistant"),
42 role: "assistant",
43 text: "Done",
44 turnId: historyTurnId,
45 createdAt: time(3),
46 updatedAt: time(4),
47 streaming: false,
48 },
49 {
50 id: MessageId.make("live-user"),
51 role: "user",
52 text: "Continue",
53 turnId: null,
54 createdAt: time(5),
55 updatedAt: time(5),
56 streaming: false,
57 },
58 {
59 id: MessageId.make("live-assistant"),
60 role: "assistant",
61 text,
62 turnId,
63 createdAt: time(7),
64 updatedAt: time(7),
65 streaming: true,
66 },
67 ];
68 const work: WorkLogEntry[] = [
69 {
70 id: "history-work",
71 turnId: historyTurnId,
72 createdAt: time(1),
73 label: "Ran command",
74 tone: "tool",
75 command: "vp test",
76 toolCallId: "history-tool",
77 toolLifecycleStatus: "failed",
78 sourceActivityKind: "tool.completed",
79 },
80 {
81 id: "live-work",
82 turnId,
83 createdAt: time(6),
84 label: "Read file",
85 tone: "tool",
86 changedFiles: ["/repo/src/index.ts"],
87 toolCallId: "live-tool",
88 toolLifecycleStatus: "completed",
89 sourceActivityKind: "tool.completed",
90 },
91 ];
92 const timeline = deriveTimelineEntriesWithState(messages, [], work);
93 const input = {
94 timelineEntries: timeline.entries,
95 latestTurn: { turnId, state: "running", startedAt: time(5), completedAt: null },
96 runningTurnId: turnId,
97 isWorking: true,
98 activeTurnStartedAt: time(5),
99 turnDiffSummaryByAssistantMessageId: new Map<MessageId, TurnDiffSummary>(),
100 revertTurnCountByUserMessageId: new Map<MessageId, number>(),
101 } satisfies Parameters<typeof deriveMessagesTimelineRows>[0];
102 return { messages, work, timeline, input, time, turnId, historyTurnId };
103 }
104
105 it.each([
106 ["", "Now visible"],
107 [" \n", "Now visible"],
108 ["Visible", ""],
109 ["", " \t\n"],
110 ["Visible", "★ Insight\nKeep these line breaks"],
111 ])("keeps row parity and history identity when text changes from %j to %j", (before, after) => {
112 const initial = fixture(before);
113 const previous = deriveMessagesTimelineRowsWithState(initial.input);
114 Object.freeze(previous.rows);
115 for (const row of previous.rows) Object.freeze(row);
116 const last = initial.messages.at(-1)!;
117 const messages = [
118 ...initial.messages.slice(0, -1),
119 { ...last, text: after, updatedAt: initial.time(8) },
120 ];
121 const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline);
122 const input = { ...initial.input, timelineEntries: timeline.entries };
123 const next = deriveMessagesTimelineRowsWithState(input, previous);
124
125 expect(next.rows).toEqual(
126 deriveMessagesTimelineRows({
127 ...input,
128 timelineEntries: deriveTimelineEntries(messages, [], initial.work),
129 }),
130 );
131 for (const [index, row] of previous.rows.entries()) {
132 if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) {
133 expect(next.rows[index]).toMatchObject({ message: { text: after } });
134 expect(row.message.text).toBe(before);
135 } else {
136 expect(next.rows[index]).toBe(row);
137 }
138 }
139 });
140
141 it("keeps earlier emitted rows unchanged across two projection branches", () => {
142 const initial = fixture();
143 const previous = deriveMessagesTimelineRowsWithState(initial.input);
144 const last = initial.messages.at(-1)!;
145 const branch = (text: string) => {
146 const messages = [...initial.messages.slice(0, -1), { ...last, text }];
147 const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline);
148 const input = { ...initial.input, timelineEntries: timeline.entries };
149 const projection = deriveMessagesTimelineRowsWithState(input, previous);
150 expect(projection.rows).toEqual(deriveMessagesTimelineRows(input));
151 return projection;
152 };
153 const first = branch("First branch");
154 const savedFirst = structuredClone(first.rows);
155 branch("Second branch");
156 expect(first.rows).toEqual(savedFirst);
157 expect(previous.rows).toEqual(deriveMessagesTimelineRows(initial.input));
158 });
159
160 it.each(["file", "image", "handoff", "streaming-image"] as const)(
161 "preserves stream reuse through the real %s preview materializer",
162 (kind) => {
163 const initial = fixture("Partial");
164 const image = {
165 type: "image" as const,
166 id: "image",
167 name: "image.png",
168 mimeType: "image/png",
169 sizeBytes: 42,
170 };
171 const file = {
172 type: "file" as const,
173 id: "file",
174 name: "file.txt",
175 mimeType: "text/plain",
176 sizeBytes: 8,
177 };
178 const source = initial.messages.map((message, index) =>
179 index === 0 || (kind === "streaming-image" && index === initial.messages.length - 1)
180 ? { ...message, attachments: kind === "file" ? [file] : [image, file] }
181 : message,
182 );
183 const server = createMessageAttachmentPreviewProjector();
184 const handoff = createMessageAttachmentPreviewProjector();
185 const materialize = (messages: ReadonlyArray<ChatMessage>, url: string) => {
186 const urls = new Map(kind === "handoff" ? [] : [[image.id, url]]);
187 return messages.map((message) => {
188 const displayed = server(message, (attachment) => urls.get(attachment.id));
189 return kind === "handoff" && message.role === "user"
190 ? handoff(displayed, (attachment) =>
191 isImageAttachment(attachment) ? "blob:handoff" : undefined,
192 )
193 : displayed;
194 });
195 };
196 const firstMessages = materialize(source, "https://first.test/image");
197 const firstTimeline = deriveTimelineEntriesWithState(firstMessages, [], initial.work);
198 const input = { ...initial.input, timelineEntries: firstTimeline.entries };
199 const previous = deriveMessagesTimelineRowsWithState(input);
200 const saved = structuredClone(previous.rows);
201 const nextSource = [...source.slice(0, -1), { ...source.at(-1)!, text: "Next token" }];
202 const nextMessages = materialize(nextSource, "https://first.test/image");
203 const nextTimeline = deriveTimelineEntriesWithState(
204 nextMessages,
205 [],
206 initial.work,
207 firstTimeline,
208 );
209 const nextInput = { ...input, timelineEntries: nextTimeline.entries };
210 const next = deriveMessagesTimelineRowsWithState(nextInput, previous);
211
212 expect(nextMessages[0]).toBe(firstMessages[0]);
213 expect(next.rows).toEqual(deriveMessagesTimelineRows(nextInput));
214 for (const [index, row] of previous.rows.entries()) {
215 if (
216 (row.kind === "message" || row.kind === "assistant-meta") &&
217 row.message.id === source.at(-1)?.id
218 )
219 continue;
220 expect(next.rows[index]).toBe(row);
221 }
222 expect(previous.rows).toEqual(saved);
223
224 if (kind === "image" || kind === "streaming-image") {
225 const renewed = materialize(nextSource, "https://renewed.test/image");
226 const renewedTimeline = deriveTimelineEntriesWithState(
227 renewed,
228 [],
229 initial.work,
230 nextTimeline,
231 );
232 const renewedInput = { ...input, timelineEntries: renewedTimeline.entries };
233 const renewedRows = deriveMessagesTimelineRowsWithState(renewedInput, next);
234 expect(renewedRows.rows).toEqual(deriveMessagesTimelineRows(renewedInput));
235 expect(renewed[0]?.attachments?.[0]).toMatchObject({
236 previewUrl: "https://renewed.test/image",
237 });
238 expect(firstMessages[0]?.attachments?.[0]).toMatchObject({
239 previewUrl: "https://first.test/image",
240 });
241 }
242 },
243 );
244
245 it.each(["completion", "turn", "role", "ordering"] as const)(
246 "rebuilds row structure for a %s change with otherwise unchanged controls",
247 (change) => {
248 const initial = fixture("Partial");
249 const input = {
250 ...initial.input,
251 latestTurn: {
252 ...initial.input.latestTurn,
253 state: "completed" as const,
254 completedAt: initial.time(9),
255 },
256 runningTurnId: null,
257 isWorking: false,
258 };
259 const previous = deriveMessagesTimelineRowsWithState(input);
260 const last = initial.messages.at(-1)!;
261 const changed: ChatMessage =
262 change === "completion"
263 ? { ...last, streaming: false, updatedAt: initial.time(10) }
264 : change === "turn"
265 ? { ...last, turnId: initial.historyTurnId }
266 : change === "role"
267 ? { ...last, role: "user", turnId: null }
268 : { ...last, createdAt: initial.time(0) };
269 const messages = [...initial.messages.slice(0, -1), changed];
270 const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline);
271 const nextInput = { ...input, timelineEntries: timeline.entries };
272 const next = deriveMessagesTimelineRowsWithState(nextInput, previous);
273 expect(next.rows).toEqual(
274 deriveMessagesTimelineRows({
275 ...nextInput,
276 timelineEntries: deriveTimelineEntries(messages, [], initial.work),
277 }),
278 );
279 expect(previous.rows).toEqual(deriveMessagesTimelineRows(input));
280 },
281 );
282
283 it("keeps full parity through completion, trailing failure, metadata, expansion, and paging", () => {
284 const initial = fixture("Partial");
285 let timeline = initial.timeline;
286 let input: Parameters<typeof deriveMessagesTimelineRows>[0] = initial.input;
287 let projection = deriveMessagesTimelineRowsWithState(input);
288 let messages = initial.messages;
289 let work = initial.work;
290 const check = (changes: Partial<typeof input> = {}) => {
291 const oldRows = structuredClone(projection.rows);
292 const previous = projection;
293 timeline = deriveTimelineEntriesWithState(messages, [], work, timeline);
294 input = { ...input, ...changes, timelineEntries: timeline.entries };
295 projection = deriveMessagesTimelineRowsWithState(input, previous);
296 expect(projection.rows).toEqual(
297 deriveMessagesTimelineRows({
298 ...input,
299 timelineEntries: deriveTimelineEntries(messages, [], work),
300 }),
301 );
302 expect(previous.rows).toEqual(oldRows);
303 };
304
305 messages = [
306 ...messages.slice(0, -1),
307 { ...messages.at(-1)!, streaming: false, text: "Complete", updatedAt: initial.time(9) },
308 ];
309 check({
310 latestTurn: { ...initial.input.latestTurn, state: "completed", completedAt: initial.time(9) },
311 runningTurnId: null,
312 isWorking: false,
313 });
314 work = [
315 ...work,
316 {
317 id: "late-failure",
318 turnId: initial.turnId,
319 createdAt: initial.time(10),
320 label: "Failed command",
321 command: "vp test",
322 tone: "error",
323 toolCallId: "failed-tool",
324 toolLifecycleStatus: "failed",
325 sourceActivityKind: "tool.completed",
326 },
327 ];
328 check();
329 messages = [
330 ...messages.slice(0, -1),
331 { ...messages.at(-1)!, text: "Updated final text", updatedAt: initial.time(11) },
332 ];
333 check();
334 check({
335 latestTurn: {
336 ...initial.input.latestTurn,
337 state: "interrupted",
338 completedAt: initial.time(12),
339 },
340 });
341 check({ expandedTurnIds: new Set([initial.historyTurnId, initial.turnId]) });
342 const group = projection.rows.find((row) => row.kind === "work-toggle");
343 check({ expandedWorkGroupIds: new Set(group ? [group.id] : []) });
344 messages = [
345 { ...messages[0]!, id: MessageId.make("older-user"), createdAt: "2026-09-03T23:59:00.000Z" },
346 ...messages,
347 ];
348 check();
349 messages = messages.map((message) =>
350 message.id === MessageId.make("live-assistant")
351 ? { ...message, role: "user", turnId: null, createdAt: initial.time(0) }
352 : message,
353 );
354 check({ revertTurnCountByUserMessageId: new Map([[MessageId.make("live-user"), 3]]) });
355 });
356});
16357
17describe("expanded tool group scrolling", () => {358describe("expanded tool group scrolling", () => {
18 const entries = [{ id: "first" }, { id: "second" }];359 const entries = [{ id: "first" }, { id: "second" }];
apps/web/src/components/chat/MessagesTimeline.logic.ts+55
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import { shallow } from "zustand/vanilla/shallow";
2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";3import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
3import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";4import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
4import {5import {
...@@ -19,6 +20,7 @@ export {...@@ -19,6 +20,7 @@ export {
19} from "@t3tools/client-runtime/work-log/presentation";20} from "@t3tools/client-runtime/work-log/presentation";
20import {21import {
21 formatDuration,22 formatDuration,
23 isStreamingMessageTextUpdate,
22 workEntryDisplayIndicatesToolFailure,24 workEntryDisplayIndicatesToolFailure,
23 workEntryIndicatesToolSuccess,25 workEntryIndicatesToolSuccess,
24 workEntryIndicatesToolNeutralStatus,26 workEntryIndicatesToolNeutralStatus,
...@@ -1153,6 +1155,59 @@ export function deriveMessagesTimelineRows(input: {...@@ -1153,6 +1155,59 @@ export function deriveMessagesTimelineRows(input: {
1153 return attachTrailingToolGroupsToAssistant(nextRows);1155 return attachTrailingToolGroupsToAssistant(nextRows);
1154}1156}
11551157
1158type MessagesTimelineRowsInput = Parameters<typeof deriveMessagesTimelineRows>[0];
1159
1160export interface MessagesTimelineRowsProjection {
1161 readonly input: MessagesTimelineRowsInput;
1162 readonly rows: MessagesTimelineRow[];
1163}
1164
1165function 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. */
1199export 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
1156export function computeStableMessagesTimelineRows(1211export function computeStableMessagesTimelineRows(
1157 rows: MessagesTimelineRow[],1212 rows: MessagesTimelineRow[],
1158 previous: StableMessagesTimelineRowsState,1213 previous: StableMessagesTimelineRowsState,
apps/web/src/components/chat/MessagesTimeline.tsx+32-17
...@@ -123,7 +123,8 @@ import {...@@ -123,7 +123,8 @@ import {
123import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget";123import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget";
124import {124import {
125 computeStableMessagesTimelineRows,125 computeStableMessagesTimelineRows,
126 deriveMessagesTimelineRows,126 deriveMessagesTimelineRowsWithState,
127 type MessagesTimelineRowsProjection,
127 liveWorkEntryLabel,128 liveWorkEntryLabel,
128 resolveAssistantMessageCopyState,129 resolveAssistantMessageCopyState,
129 resolveTimelineIsAtEnd,130 resolveTimelineIsAtEnd,
...@@ -504,9 +505,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -504,9 +505,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({
504 });505 });
505 }, [latestTurn]);506 }, [latestTurn]);
506507
507 const rawRows = useMemo(508 const rowsProjectionRef = useRef<{
508 () =>509 threadKey: string;
509 deriveMessagesTimelineRows({510 workspaceRoot: string | undefined;
511 projection: MessagesTimelineRowsProjection;
512 } | null>(null);
513 const rawRows = useMemo(() => {
514 const previous = rowsProjectionRef.current;
515 const projection = deriveMessagesTimelineRowsWithState(
516 {
510 timelineEntries,517 timelineEntries,
511 latestTurn,518 latestTurn,
512 runningTurnId,519 runningTurnId,
...@@ -516,19 +523,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -516,19 +523,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({
516 activeTurnStartedAt,523 activeTurnStartedAt,
517 turnDiffSummaryByAssistantMessageId,524 turnDiffSummaryByAssistantMessageId,
518 revertTurnCountByUserMessageId,525 revertTurnCountByUserMessageId,
519 }),526 },
520 [527 previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot
521 timelineEntries,528 ? previous.projection
522 latestTurn,529 : null,
523 runningTurnId,530 );
524 expandedTurnIds,531 rowsProjectionRef.current = { threadKey: routeThreadKey, workspaceRoot, projection };
525 expandedWorkGroupIds,532 return projection.rows;
526 isWorking,533 }, [
527 activeTurnStartedAt,534 rowsProjectionRef,
528 turnDiffSummaryByAssistantMessageId,535 routeThreadKey,
529 revertTurnCountByUserMessageId,536 workspaceRoot,
530 ],537 timelineEntries,
531 );538 latestTurn,
539 runningTurnId,
540 expandedTurnIds,
541 expandedWorkGroupIds,
542 isWorking,
543 activeTurnStartedAt,
544 turnDiffSummaryByAssistantMessageId,
545 revertTurnCountByUserMessageId,
546 ]);
532 const rows = useStableRows(rawRows);547 const rows = useStableRows(rawRows);
533 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);548 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
534 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(549 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
apps/web/src/session-logic.test.ts+168
...@@ -10,11 +10,13 @@ import { describe, expect, it } from "vite-plus/test";...@@ -10,11 +10,13 @@ import { describe, expect, it } from "vite-plus/test";
10import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation";10import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation";
1111
12import {12import {
13 createMessageAttachmentPreviewProjector,
13 deriveActiveWorkStartedAt,14 deriveActiveWorkStartedAt,
14 deriveActivePlanState,15 deriveActivePlanState,
15 derivePendingApprovals,16 derivePendingApprovals,
16 derivePendingUserInputs,17 derivePendingUserInputs,
17 deriveTimelineEntries,18 deriveTimelineEntries,
19 deriveTimelineEntriesWithState,
18 deriveWorkLogEntries,20 deriveWorkLogEntries,
19 findLatestProposedPlan,21 findLatestProposedPlan,
20 hasActionableProposedPlan,22 hasActionableProposedPlan,
...@@ -2074,6 +2076,172 @@ describe("deriveWorkLogEntries", () => {...@@ -2074,6 +2076,172 @@ describe("deriveWorkLogEntries", () => {
2074});2076});
20752077
2076describe("deriveTimelineEntries", () => {2078describe("deriveTimelineEntries", () => {
2079 const streamingMessage = {
2080 id: MessageId.make("streaming-message"),
2081 role: "assistant" as const,
2082 text: "",
2083 turnId: TurnId.make("streaming-turn"),
2084 createdAt: "2026-02-23T00:00:03.000Z",
2085 updatedAt: "2026-02-23T00:00:03.000Z",
2086 streaming: true,
2087 };
2088
2089 it("reuses preview objects while preserving URL and attachment metadata changes", () => {
2090 const image = {
2091 type: "image" as const,
2092 id: "image",
2093 name: "image.png",
2094 mimeType: "image/png",
2095 sizeBytes: 42,
2096 };
2097 const file = {
2098 type: "file" as const,
2099 id: "file",
2100 name: "file.txt",
2101 mimeType: "text/plain",
2102 sizeBytes: 8,
2103 };
2104 const message = { ...streamingMessage, attachments: Object.freeze([image, file]) };
2105 const project = createMessageAttachmentPreviewProjector();
2106 const urls = new Map([[image.id, "https://first.test/image"]]);
2107 const first = project(message, (attachment) => urls.get(attachment.id));
2108 Object.freeze(first.attachments);
2109 expect(project(message, (attachment) => new Map(urls).get(attachment.id))).toBe(first);
2110 const streamed = project({ ...message, text: "Next" }, (attachment) => urls.get(attachment.id));
2111 expect(streamed.attachments).toBe(first.attachments);
2112 expect(streamed.text).toBe("Next");
2113 expect(first.text).toBe("");
2114 expect(first.attachments?.[1]).toBe(file);
2115
2116 urls.set(image.id, "https://second.test/image");
2117 const renewed = project(message, (attachment) => urls.get(attachment.id));
2118 expect(renewed.attachments?.[0]).toMatchObject({ previewUrl: "https://second.test/image" });
2119 expect(first.attachments?.[0]).toMatchObject({ previewUrl: "https://first.test/image" });
2120 const renamed = project(
2121 { ...message, attachments: [{ ...image, name: "renamed.png" }, file] },
2122 (attachment) => urls.get(attachment.id),
2123 );
2124 expect(renamed.attachments?.[0]).toMatchObject({
2125 name: "renamed.png",
2126 previewUrl: "https://second.test/image",
2127 });
2128 expect(project(message, () => undefined)).toBe(message);
2129 });
2130
2131 it("keeps pending preview handoffs stable and restores the current server URL", () => {
2132 const message = {
2133 ...streamingMessage,
2134 role: "user" as const,
2135 streaming: false,
2136 attachments: [
2137 {
2138 type: "image" as const,
2139 id: "image",
2140 name: "image.png",
2141 mimeType: "image/png",
2142 sizeBytes: 42,
2143 },
2144 ],
2145 };
2146 const server = createMessageAttachmentPreviewProjector();
2147 const handoff = createMessageAttachmentPreviewProjector();
2148 const first = handoff(
2149 server(message, () => undefined),
2150 () => "blob:handoff",
2151 );
2152 expect(
2153 handoff(
2154 server(message, () => undefined),
2155 () => "blob:handoff",
2156 ),
2157 ).toBe(first);
2158 const ready = server(message, () => "https://server.test/image");
2159 expect(handoff(ready, () => "blob:handoff").attachments?.[0]).toMatchObject({
2160 previewUrl: "blob:handoff",
2161 });
2162 expect(handoff(ready, () => undefined)).toBe(ready);
2163 expect(ready.attachments?.[0]).toMatchObject({ previewUrl: "https://server.test/image" });
2164 expect(first.attachments?.[0]).toMatchObject({ previewUrl: "blob:handoff" });
2165 });
2166
2167 it("reuses ordered history without changing an earlier projection", () => {
2168 const history = { ...streamingMessage, id: MessageId.make("history"), streaming: false };
2169 const work = [
2170 { id: "work", createdAt: history.createdAt, label: "Ran tests", tone: "tool" as const },
2171 ];
2172 const first = deriveTimelineEntriesWithState([history, streamingMessage], [], work);
2173 Object.freeze(first.entries);
2174 for (const entry of first.entries) Object.freeze(entry);
2175
2176 const firstMessage = {
2177 ...streamingMessage,
2178 text: "First",
2179 updatedAt: "2026-02-23T00:00:04.000Z",
2180 };
2181 const secondMessage = {
2182 ...streamingMessage,
2183 text: "Second",
2184 updatedAt: "2026-02-23T00:00:05.000Z",
2185 };
2186 const firstBranch = deriveTimelineEntriesWithState([history, firstMessage], [], work, first);
2187 const secondBranch = deriveTimelineEntriesWithState([history, secondMessage], [], work, first);
2188
2189 expect(firstBranch.entries).toEqual(deriveTimelineEntries([history, firstMessage], [], work));
2190 expect(secondBranch.entries).toEqual(deriveTimelineEntries([history, secondMessage], [], work));
2191 expect(firstBranch.entries[0]).toBe(first.entries[0]);
2192 expect(firstBranch.entries[2]).toBe(first.entries[2]);
2193 expect(first.entries[1]).toMatchObject({ message: { text: "" } });
2194 expect(firstBranch.entries[1]).toMatchObject({ message: { text: "First" } });
2195 });
2196
2197 it("preserves stable source ordering for ties, append, and older pages", () => {
2198 const plan = {
2199 id: "plan:thread:turn",
2200 turnId: streamingMessage.turnId,
2201 planMarkdown: "Plan",
2202 implementedAt: null,
2203 implementationThreadId: null,
2204 createdAt: streamingMessage.createdAt,
2205 updatedAt: streamingMessage.createdAt,
2206 };
2207 const firstWork = {
2208 id: "work-1",
2209 createdAt: streamingMessage.createdAt,
2210 label: "Ran tests",
2211 tone: "tool" as const,
2212 };
2213 const first = deriveTimelineEntriesWithState([streamingMessage], [plan], [firstWork]);
2214 const appendedMessage = { ...streamingMessage, id: MessageId.make("appended") };
2215 const appendedWork = { ...firstWork, id: "work-2" };
2216 const messages = [streamingMessage, appendedMessage];
2217 const work = [firstWork, appendedWork];
2218 const appended = deriveTimelineEntriesWithState(messages, [plan], work, first);
2219 expect(appended.entries.map((entry) => entry.id)).toEqual([
2220 streamingMessage.id,
2221 appendedMessage.id,
2222 plan.id,
2223 firstWork.id,
2224 appendedWork.id,
2225 ]);
2226 expect(appended.entries[0]).toBe(first.entries[0]);
2227
2228 const older = {
2229 ...streamingMessage,
2230 id: MessageId.make("older"),
2231 createdAt: "2026-02-22T00:00:00.000Z",
2232 };
2233 const prepended = deriveTimelineEntriesWithState([older, ...messages], [plan], work, appended);
2234 expect(prepended.entries).toEqual(deriveTimelineEntries([older, ...messages], [plan], work));
2235 const corrected = {
2236 ...streamingMessage,
2237 createdAt: "2026-02-24T00:00:00.000Z",
2238 streaming: false,
2239 };
2240 expect(
2241 deriveTimelineEntriesWithState([corrected, appendedMessage], [plan], work, appended).entries,
2242 ).toEqual(deriveTimelineEntries([corrected, appendedMessage], [plan], work));
2243 });
2244
2077 it("includes proposed plans alongside messages and work entries in chronological order", () => {2245 it("includes proposed plans alongside messages and work entries in chronological order", () => {
2078 const entries = deriveTimelineEntries(2246 const entries = deriveTimelineEntries(
2079 [2247 [
apps/web/src/session-logic.ts+241-17
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Option from "effect/Option";1import * as Option from "effect/Option";
2import * as Arr from "effect/Array";2import * as Arr from "effect/Array";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import { shallow } from "zustand/vanilla/shallow";
4import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";5import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
5import {6import {
6 commandDetailRepeatsCommand,7 commandDetailRepeatsCommand,
...@@ -24,6 +25,7 @@ import {...@@ -24,6 +25,7 @@ import {
24} from "@t3tools/contracts";25} from "@t3tools/contracts";
2526
26import type {27import type {
28 ChatAttachment,
27 ChatMessage,29 ChatMessage,
28 ProposedPlan,30 ProposedPlan,
29 SessionPhase,31 SessionPhase,
...@@ -192,6 +194,13 @@ export type TimelineEntry =...@@ -192,6 +194,13 @@ export type TimelineEntry =
192 entry: WorkLogEntry;194 entry: WorkLogEntry;
193 };195 };
194196
197export interface TimelineEntriesProjection {
198 readonly messages: ReadonlyArray<ChatMessage>;
199 readonly proposedPlans: ReadonlyArray<ProposedPlan>;
200 readonly workEntries: ReadonlyArray<WorkLogEntry>;
201 readonly entries: TimelineEntry[];
202}
203
195export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {204export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
196 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {205 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {
197 return true;206 return true;
...@@ -1774,32 +1783,247 @@ function compareActivityLifecycleRank(kind: string): number {...@@ -1774,32 +1783,247 @@ function compareActivityLifecycleRank(kind: string): number {
1774 return 1;1783 return 1;
1775}1784}
17761785
1777export function deriveTimelineEntries(1786function timelineEntryFromMessage(message: ChatMessage): TimelineEntry {
1778 messages: ReadonlyArray<ChatMessage>,1787 return {
1779 proposedPlans: ReadonlyArray<ProposedPlan>,
1780 workEntries: ReadonlyArray<WorkLogEntry>,
1781): TimelineEntry[] {
1782 const messageRows: TimelineEntry[] = messages.map((message) => ({
1783 id: message.id,1788 id: message.id,
1784 kind: "message",1789 kind: "message",
1785 createdAt: message.createdAt,1790 createdAt: message.createdAt,
1786 message,1791 message,
1787 }));1792 };
1788 const proposedPlanRows: TimelineEntry[] = proposedPlans.map((proposedPlan) => ({1793}
1794
1795function timelineEntryFromProposedPlan(proposedPlan: ProposedPlan): TimelineEntry {
1796 return {
1789 id: proposedPlan.id,1797 id: proposedPlan.id,
1790 kind: "proposed-plan",1798 kind: "proposed-plan",
1791 createdAt: proposedPlan.createdAt,1799 createdAt: proposedPlan.createdAt,
1792 proposedPlan,1800 proposedPlan,
1793 }));1801 };
1794 const workRows: TimelineEntry[] = workEntries.map((entry) => ({1802}
1795 id: entry.id,1803
1804function timelineEntryFromWork(workEntry: WorkLogEntry): TimelineEntry {
1805 return {
1806 id: workEntry.id,
1796 kind: "work",1807 kind: "work",
1797 createdAt: entry.createdAt,1808 createdAt: workEntry.createdAt,
1798 entry,1809 entry: workEntry,
1799 }));1810 };
1800 return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) =>1811}
1801 a.createdAt.localeCompare(b.createdAt),1812
1802 );1813function compareTimelineEntriesByCreatedAt(left: TimelineEntry, right: TimelineEntry): number {
1814 return left.createdAt.localeCompare(right.createdAt);
1815}
1816
1817function timelineEntrySourceOrder(entry: TimelineEntry): number {
1818 switch (entry.kind) {
1819 case "message":
1820 return 0;
1821 case "proposed-plan":
1822 return 1;
1823 case "work":
1824 return 2;
1825 }
1826}
1827
1828function shouldTakePreviousTimelineEntry(previous: TimelineEntry, suffix: TimelineEntry): boolean {
1829 const createdAtComparison = compareTimelineEntriesByCreatedAt(previous, suffix);
1830 if (createdAtComparison !== 0) return createdAtComparison < 0;
1831 // The original full derivation sorts a source-ordered array with a stable
1832 // comparator. On a tie, messages precede plans, plans precede work, and an
1833 // older item in the same source array precedes a newly appended item.
1834 return timelineEntrySourceOrder(previous) <= timelineEntrySourceOrder(suffix);
1835}
1836
1837function hasExactArrayPrefix<T>(previous: ReadonlyArray<T>, next: ReadonlyArray<T>): boolean {
1838 if (previous === next) return true;
1839 if (next.length < previous.length) return false;
1840 for (let index = 0; index < previous.length; index += 1) {
1841 if (previous[index] !== next[index]) return false;
1842 }
1843 return true;
1844}
1845
1846function mergeTimelineEntrySuffix(
1847 previous: ReadonlyArray<TimelineEntry>,
1848 suffix: ReadonlyArray<TimelineEntry>,
1849): TimelineEntry[] {
1850 if (suffix.length === 0) return [...previous];
1851 const previousLast = previous.at(-1);
1852 let suffixIsOrdered = true;
1853 for (let index = 1; index < suffix.length; index += 1) {
1854 if (compareTimelineEntriesByCreatedAt(suffix[index - 1]!, suffix[index]!) > 0) {
1855 suffixIsOrdered = false;
1856 break;
1857 }
1858 }
1859 if (
1860 suffixIsOrdered &&
1861 (previousLast === undefined || shouldTakePreviousTimelineEntry(previousLast, suffix[0]!))
1862 ) {
1863 return [...previous, ...suffix];
1864 }
1865
1866 const merged: TimelineEntry[] = [];
1867 let previousIndex = 0;
1868 let suffixIndex = 0;
1869 while (previousIndex < previous.length || suffixIndex < suffix.length) {
1870 const previousEntry = previous[previousIndex];
1871 const suffixEntry = suffix[suffixIndex];
1872 if (
1873 previousEntry !== undefined &&
1874 (suffixEntry === undefined || shouldTakePreviousTimelineEntry(previousEntry, suffixEntry))
1875 ) {
1876 merged.push(previousEntry);
1877 previousIndex += 1;
1878 } else if (suffixEntry !== undefined) {
1879 merged.push(suffixEntry);
1880 suffixIndex += 1;
1881 }
1882 }
1883 return merged;
1884}
1885
1886/** Own one mapper per preview stage. Immutable messages retain unchanged preview objects. */
1887export function createMessageAttachmentPreviewProjector() {
1888 const attachmentsBySource = new WeakMap<
1889 ReadonlyArray<ChatAttachment>,
1890 ReadonlyArray<ChatAttachment>
1891 >();
1892 const messagesBySource = new WeakMap<ChatMessage, ChatMessage>();
1893 return (
1894 message: ChatMessage,
1895 previewUrlFor: (attachment: ChatAttachment) => string | undefined,
1896 ): ChatMessage => {
1897 const source = message.attachments;
1898 if (!source || source.length === 0) return message;
1899 const previous = attachmentsBySource.get(source) ?? source;
1900 let changed: ChatAttachment[] | undefined;
1901 let hasOverrides = false;
1902 for (const [index, attachment] of source.entries()) {
1903 const previewUrl = previewUrlFor(attachment);
1904 const sourceUrl = "previewUrl" in attachment ? attachment.previewUrl : undefined;
1905 const previousAttachment = previous[index]!;
1906 const previousUrl =
1907 "previewUrl" in previousAttachment ? previousAttachment.previewUrl : undefined;
1908 const next =
1909 !previewUrl || previewUrl === sourceUrl
1910 ? attachment
1911 : previewUrl === previousUrl
1912 ? previousAttachment
1913 : { ...attachment, previewUrl };
1914 hasOverrides ||= next !== attachment;
1915 if (next !== previousAttachment) {
1916 changed ??= previous.slice();
1917 changed[index] = next;
1918 }
1919 }
1920 const attachments = hasOverrides ? (changed ?? previous) : source;
1921 attachmentsBySource.set(source, attachments);
1922 if (attachments === source) {
1923 messagesBySource.delete(message);
1924 return message;
1925 }
1926 const previousMessage = messagesBySource.get(message);
1927 if (previousMessage?.attachments === attachments) return previousMessage;
1928 const result = { ...message, attachments };
1929 messagesBySource.set(message, result);
1930 return result;
1931 };
1932}
1933
1934/** Text and update time do not change a streaming assistant message's timeline structure. */
1935export function isStreamingMessageTextUpdate(previous: ChatMessage, next: ChatMessage): boolean {
1936 if (
1937 previous.role !== "assistant" ||
1938 next.role !== "assistant" ||
1939 !previous.streaming ||
1940 !next.streaming
1941 ) {
1942 return false;
1943 }
1944 const { text: _previousText, updatedAt: _previousUpdatedAt, ...previousMetadata } = previous;
1945 const { text: _nextText, updatedAt: _nextUpdatedAt, ...nextMetadata } = next;
1946 return shallow(previousMetadata, nextMetadata);
1947}
1948
1949function replaceStreamingTimelineMessages(
1950 messages: ReadonlyArray<ChatMessage>,
1951 previous: TimelineEntriesProjection,
1952): TimelineEntry[] | null {
1953 if (messages.length !== previous.messages.length) return null;
1954 const replacements = new Map<ChatMessage, ChatMessage>();
1955 for (const [index, message] of messages.entries()) {
1956 const previousMessage = previous.messages[index]!;
1957 if (message === previousMessage) continue;
1958 if (!isStreamingMessageTextUpdate(previousMessage, message)) return null;
1959 replacements.set(previousMessage, message);
1960 }
1961 if (replacements.size === 0) return previous.entries;
1962 return previous.entries.map((entry) => {
1963 const replacement = entry.kind === "message" ? replacements.get(entry.message) : undefined;
1964 return replacement ? timelineEntryFromMessage(replacement) : entry;
1965 });
1966}
1967
1968/** Reuse ordered entries across immutable stream updates. Other changes keep the full sort. */
1969export function deriveTimelineEntriesWithState(
1970 messages: ReadonlyArray<ChatMessage>,
1971 proposedPlans: ReadonlyArray<ProposedPlan>,
1972 workEntries: ReadonlyArray<WorkLogEntry>,
1973 previous: TimelineEntriesProjection | null = null,
1974): TimelineEntriesProjection {
1975 if (
1976 previous !== null &&
1977 previous.proposedPlans.length === proposedPlans.length &&
1978 previous.workEntries.length === workEntries.length &&
1979 hasExactArrayPrefix(previous.proposedPlans, proposedPlans) &&
1980 hasExactArrayPrefix(previous.workEntries, workEntries)
1981 ) {
1982 const entries = replaceStreamingTimelineMessages(messages, previous);
1983 if (entries !== null) return { messages, proposedPlans, workEntries, entries };
1984 }
1985 const canAppend =
1986 previous !== null &&
1987 hasExactArrayPrefix(previous.messages, messages) &&
1988 hasExactArrayPrefix(previous.proposedPlans, proposedPlans) &&
1989 hasExactArrayPrefix(previous.workEntries, workEntries);
1990
1991 if (canAppend) {
1992 const messageRows = messages.slice(previous.messages.length).map(timelineEntryFromMessage);
1993 const proposedPlanRows = proposedPlans
1994 .slice(previous.proposedPlans.length)
1995 .map(timelineEntryFromProposedPlan);
1996 const workRows = workEntries.slice(previous.workEntries.length).map(timelineEntryFromWork);
1997 const suffix = [...messageRows, ...proposedPlanRows, ...workRows].toSorted(
1998 compareTimelineEntriesByCreatedAt,
1999 );
2000 return {
2001 messages,
2002 proposedPlans,
2003 workEntries,
2004 entries: mergeTimelineEntrySuffix(previous.entries, suffix),
2005 };
2006 }
2007
2008 const messageRows = messages.map(timelineEntryFromMessage);
2009 const proposedPlanRows = proposedPlans.map(timelineEntryFromProposedPlan);
2010 const workRows = workEntries.map(timelineEntryFromWork);
2011 return {
2012 messages,
2013 proposedPlans,
2014 workEntries,
2015 entries: [...messageRows, ...proposedPlanRows, ...workRows].toSorted(
2016 compareTimelineEntriesByCreatedAt,
2017 ),
2018 };
2019}
2020
2021export function deriveTimelineEntries(
2022 messages: ReadonlyArray<ChatMessage>,
2023 proposedPlans: ReadonlyArray<ProposedPlan>,
2024 workEntries: ReadonlyArray<WorkLogEntry>,
2025): TimelineEntry[] {
2026 return deriveTimelineEntriesWithState(messages, proposedPlans, workEntries).entries;
1803}2027}
18042028
1805export function inferCheckpointTurnCountByTurnId(2029export function inferCheckpointTurnCountByTurnId(