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 {
9393 parseStandaloneComposerSlashCommand,
9494} from "../composer-logic";
9595import {
96 createMessageAttachmentPreviewProjector,
9697 derivePendingApprovals,
9798 derivePendingUserInputs,
9899 derivePhase,
99 deriveTimelineEntries,
100 deriveTimelineEntriesWithState,
100101 deriveActiveWorkStartedAt,
101102 deriveActivePlanState,
102103 findLatestProposedPlan,
103104 deriveWorkLogEntries,
104105 hasActionableProposedPlan,
105106 isLatestTurnSettled,
107 type TimelineEntriesProjection,
106108} from "../session-logic";
107109import { type LegendListRef } from "@legendapp/list/react";
108110import {
......@@ -2706,6 +2708,8 @@ export default function ChatView(props: ChatViewProps) {
27062708 });
27072709 }, []);
27082710 const serverMessages = activeThread?.messages;
2711 const [projectServerMessagePreviews] = useState(createMessageAttachmentPreviewProjector);
2712 const [projectHandoffMessagePreviews] = useState(createMessageAttachmentPreviewProjector);
27092713 const downloadFileAttachment = useCallback(
27102714 async (attachment: ChatFileAttachment) => {
27112715 const connection = readPreparedConnection(environmentId);
......@@ -2777,19 +2781,12 @@ export default function ChatView(props: ChatViewProps) {
27772781 );
27782782 const displayServerMessages = useMemo<ReadonlyArray<ChatMessage>>(() => {
27792783 if (!serverMessages) return [];
2780 return serverMessages.map((message) => {
2781 if (!message.attachments || message.attachments.length === 0) {
2782 return message;
2783 }
2784 return {
2785 ...message,
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]);
2784 return serverMessages.map((message) =>
2785 projectServerMessagePreviews(message, (attachment) =>
2786 serverAttachmentUrlById.get(attachment.id),
2787 ),
2788 );
2789 }, [projectServerMessagePreviews, serverAttachmentUrlById, serverMessages]);
27932790 useEffect(() => {
27942791 if (typeof Image === "undefined" || displayServerMessages.length === 0) {
27952792 return;
......@@ -2879,10 +2876,7 @@ export default function ChatView(props: ChatViewProps) {
28792876 const serverMessagesWithPreviewHandoff =
28802877 Object.keys(attachmentPreviewHandoffByMessageId).length === 0
28812878 ? messages
2882 : // Spread only fires for the few messages that actually changed;
2883 // unchanged ones early-return their original reference.
2884 // In-place mutation would break React's immutable state contract.
2885 messages.map((message) => {
2879 : messages.map((message) => {
28862880 if (
28872881 message.role !== "user" ||
28882882 !message.attachments ||
......@@ -2895,25 +2889,15 @@ export default function ChatView(props: ChatViewProps) {
28952889 return message;
28962890 }
28972891
2898 let changed = false;
28992892 let imageIndex = 0;
2900 const attachments = message.attachments.map((attachment) => {
2893 return projectHandoffMessagePreviews(message, (attachment) => {
29012894 if (!isImageAttachment(attachment)) {
2902 return attachment;
2895 return undefined;
29032896 }
29042897 const handoffPreviewUrl = handoffPreviewUrls[imageIndex];
29052898 imageIndex += 1;
2906 if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) {
2907 return attachment;
2908 }
2909 changed = true;
2910 return {
2911 ...attachment,
2912 previewUrl: handoffPreviewUrl,
2913 };
2899 return handoffPreviewUrl;
29142900 });
2915
2916 return changed ? { ...message, attachments } : message;
29172901 });
29182902
29192903 const localMessages = [
......@@ -2938,12 +2922,29 @@ export default function ChatView(props: ChatViewProps) {
29382922 displayServerMessages,
29392923 feedbackSubmissions,
29402924 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,
29412947 ]);
2942 const timelineEntries = useMemo(
2943 () =>
2944 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
2945 [activeThread?.proposedPlans, timelineMessages, workLogEntries],
2946 );
29472948 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
29482949 const draftHeroDockRequested =
29492950 activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+341
......@@ -4,6 +4,7 @@ import {
44 computeStableMessagesTimelineRows,
55 computeMessageDurationStart,
66 deriveMessagesTimelineRows,
7 deriveMessagesTimelineRowsWithState,
78 liveWorkEntryLabel,
89 normalizeCompactToolLabel,
910 resolveAssistantMessageCopyState,
......@@ -13,6 +14,346 @@ import {
1314 type MessagesTimelineRow,
1415 workEntryDisplayLabel,
1516} 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
17358describe("expanded tool group scrolling", () => {
18359 const entries = [{ id: "first" }, { id: "second" }];
apps/web/src/components/chat/MessagesTimeline.logic.ts+55
......@@ -1,4 +1,5 @@
11import * as Equal from "effect/Equal";
2import { shallow } from "zustand/vanilla/shallow";
23import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
34import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
45import {
......@@ -19,6 +20,7 @@ export {
1920} from "@t3tools/client-runtime/work-log/presentation";
2021import {
2122 formatDuration,
23 isStreamingMessageTextUpdate,
2224 workEntryDisplayIndicatesToolFailure,
2325 workEntryIndicatesToolSuccess,
2426 workEntryIndicatesToolNeutralStatus,
......@@ -1153,6 +1155,59 @@ export function deriveMessagesTimelineRows(input: {
11531155 return attachTrailingToolGroupsToAssistant(nextRows);
11541156}
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
11561211export function computeStableMessagesTimelineRows(
11571212 rows: MessagesTimelineRow[],
11581213 previous: StableMessagesTimelineRowsState,
apps/web/src/components/chat/MessagesTimeline.tsx+32-17
......@@ -123,7 +123,8 @@ import {
123123import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget";
124124import {
125125 computeStableMessagesTimelineRows,
126 deriveMessagesTimelineRows,
126 deriveMessagesTimelineRowsWithState,
127 type MessagesTimelineRowsProjection,
127128 liveWorkEntryLabel,
128129 resolveAssistantMessageCopyState,
129130 resolveTimelineIsAtEnd,
......@@ -504,9 +505,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({
504505 });
505506 }, [latestTurn]);
506507
507 const rawRows = useMemo(
508 () =>
509 deriveMessagesTimelineRows({
508 const rowsProjectionRef = useRef<{
509 threadKey: string;
510 workspaceRoot: string | undefined;
511 projection: MessagesTimelineRowsProjection;
512 } | null>(null);
513 const rawRows = useMemo(() => {
514 const previous = rowsProjectionRef.current;
515 const projection = deriveMessagesTimelineRowsWithState(
516 {
510517 timelineEntries,
511518 latestTurn,
512519 runningTurnId,
......@@ -516,19 +523,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({
516523 activeTurnStartedAt,
517524 turnDiffSummaryByAssistantMessageId,
518525 revertTurnCountByUserMessageId,
519 }),
520 [
521 timelineEntries,
522 latestTurn,
523 runningTurnId,
524 expandedTurnIds,
525 expandedWorkGroupIds,
526 isWorking,
527 activeTurnStartedAt,
528 turnDiffSummaryByAssistantMessageId,
529 revertTurnCountByUserMessageId,
530 ],
531 );
526 },
527 previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot
528 ? previous.projection
529 : null,
530 );
531 rowsProjectionRef.current = { threadKey: routeThreadKey, workspaceRoot, projection };
532 return projection.rows;
533 }, [
534 rowsProjectionRef,
535 routeThreadKey,
536 workspaceRoot,
537 timelineEntries,
538 latestTurn,
539 runningTurnId,
540 expandedTurnIds,
541 expandedWorkGroupIds,
542 isWorking,
543 activeTurnStartedAt,
544 turnDiffSummaryByAssistantMessageId,
545 revertTurnCountByUserMessageId,
546 ]);
532547 const rows = useStableRows(rawRows);
533548 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
534549 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";
1010import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation";
1111
1212import {
13 createMessageAttachmentPreviewProjector,
1314 deriveActiveWorkStartedAt,
1415 deriveActivePlanState,
1516 derivePendingApprovals,
1617 derivePendingUserInputs,
1718 deriveTimelineEntries,
19 deriveTimelineEntriesWithState,
1820 deriveWorkLogEntries,
1921 findLatestProposedPlan,
2022 hasActionableProposedPlan,
......@@ -2074,6 +2076,172 @@ describe("deriveWorkLogEntries", () => {
20742076});
20752077
20762078describe("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
20772245 it("includes proposed plans alongside messages and work entries in chronological order", () => {
20782246 const entries = deriveTimelineEntries(
20792247 [
apps/web/src/session-logic.ts+241-17
......@@ -1,6 +1,7 @@
11import * as Option from "effect/Option";
22import * as Arr from "effect/Array";
33import * as Schema from "effect/Schema";
4import { shallow } from "zustand/vanilla/shallow";
45import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
56import {
67 commandDetailRepeatsCommand,
......@@ -24,6 +25,7 @@ import {
2425} from "@t3tools/contracts";
2526
2627import type {
28 ChatAttachment,
2729 ChatMessage,
2830 ProposedPlan,
2931 SessionPhase,
......@@ -192,6 +194,13 @@ export type TimelineEntry =
192194 entry: WorkLogEntry;
193195 };
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
195204export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
196205 if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") {
197206 return true;
......@@ -1774,32 +1783,247 @@ function compareActivityLifecycleRank(kind: string): number {
17741783 return 1;
17751784}
17761785
1777export function deriveTimelineEntries(
1778 messages: ReadonlyArray<ChatMessage>,
1779 proposedPlans: ReadonlyArray<ProposedPlan>,
1780 workEntries: ReadonlyArray<WorkLogEntry>,
1781): TimelineEntry[] {
1782 const messageRows: TimelineEntry[] = messages.map((message) => ({
1786function timelineEntryFromMessage(message: ChatMessage): TimelineEntry {
1787 return {
17831788 id: message.id,
17841789 kind: "message",
17851790 createdAt: message.createdAt,
17861791 message,
1787 }));
1788 const proposedPlanRows: TimelineEntry[] = proposedPlans.map((proposedPlan) => ({
1792 };
1793}
1794
1795function timelineEntryFromProposedPlan(proposedPlan: ProposedPlan): TimelineEntry {
1796 return {
17891797 id: proposedPlan.id,
17901798 kind: "proposed-plan",
17911799 createdAt: proposedPlan.createdAt,
17921800 proposedPlan,
1793 }));
1794 const workRows: TimelineEntry[] = workEntries.map((entry) => ({
1795 id: entry.id,
1801 };
1802}
1803
1804function timelineEntryFromWork(workEntry: WorkLogEntry): TimelineEntry {
1805 return {
1806 id: workEntry.id,
17961807 kind: "work",
1797 createdAt: entry.createdAt,
1798 entry,
1799 }));
1800 return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) =>
1801 a.createdAt.localeCompare(b.createdAt),
1802 );
1808 createdAt: workEntry.createdAt,
1809 entry: workEntry,
1810 };
1811}
1812
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;
18032027}
18042028
18052029export function inferCheckpointTurnCountByTurnId(