authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 02:18:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:18:05-07:00
log076d753ae6cb97136b6b4199fb8fc0b379282dba
treec84ee82b9f95f96e405d0213f8a839a202b883c8
parent17490c0a00a6cf9a542fc8fa5d85f489fa662e70
signature Signed by PGP key B5690EEEBB952194

perf(web): skip checkpoint map rebuilds while streaming (#10118)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

7 files changed, 451 insertions(+), 373 deletions(-)

apps/web/src/components/ChatView.logic.test.ts-125
......@@ -1,6 +1,5 @@
11import {
22 ANTIGRAVITY_DEFAULT_MODEL,
3 CheckpointRef,
43 EnvironmentId,
54 MessageId,
65 ProjectId,
......@@ -13,7 +12,6 @@ import {
1312import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1413
1514import type { Thread, ThreadShell, TurnDiffSummary } from "../types";
16import type { TimelineEntry } from "../session-logic";
1715import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
1816import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
1917import type { RightPanelSurface } from "../rightPanelStore";
......@@ -24,7 +22,6 @@ import {
2422 branchMismatchKey,
2523 buildExpiredTerminalContextToastCopy,
2624 buildLoadingThreadFromShell,
27 buildRevertTurnCountByUserMessageId,
2825 buildThreadTurnInterruptInput,
2926 createLocalDispatchSnapshot,
3027 deriveComposerSendState,
......@@ -1127,128 +1124,6 @@ describe("resolveComposerInteractionMode", () => {
11271124 });
11281125});
11291126
1130describe("buildRevertTurnCountByUserMessageId", () => {
1131 const userMessageId = MessageId.make("rewind-user-message");
1132 const assistantMessageId = MessageId.make("rewind-assistant-message");
1133 const turnId = TurnId.make("rewind-turn");
1134 const timelineEntries = [
1135 {
1136 id: userMessageId,
1137 kind: "message",
1138 createdAt: now,
1139 message: {
1140 id: userMessageId,
1141 role: "user",
1142 text: "Update the file",
1143 turnId,
1144 createdAt: now,
1145 updatedAt: now,
1146 streaming: false,
1147 },
1148 },
1149 {
1150 id: assistantMessageId,
1151 kind: "message",
1152 createdAt: now,
1153 message: {
1154 id: assistantMessageId,
1155 role: "assistant",
1156 text: "Updated the file",
1157 turnId,
1158 createdAt: now,
1159 updatedAt: now,
1160 streaming: false,
1161 },
1162 },
1163 ] satisfies ReadonlyArray<TimelineEntry>;
1164 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>([
1165 [
1166 assistantMessageId,
1167 {
1168 turnId,
1169 checkpointTurnCount: 1,
1170 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/rewind-turn"),
1171 status: "ready",
1172 files: [],
1173 assistantMessageId,
1174 completedAt: now,
1175 },
1176 ],
1177 ]);
1178
1179 it("offers the checkpoint before the user message when conversation rollback is supported", () => {
1180 expect(
1181 buildRevertTurnCountByUserMessageId({
1182 supportsConversationRollback: true,
1183 timelineEntries,
1184 turnDiffSummaryByAssistantMessageId,
1185 inferredCheckpointTurnCountByTurnId: {},
1186 }),
1187 ).toEqual(new Map([[userMessageId, 0]]));
1188 });
1189
1190 it("offers no rewind action when file checkpoints exist but conversation rollback is unsupported", () => {
1191 expect(
1192 buildRevertTurnCountByUserMessageId({
1193 supportsConversationRollback: false,
1194 timelineEntries,
1195 turnDiffSummaryByAssistantMessageId,
1196 inferredCheckpointTurnCountByTurnId: {},
1197 }).size,
1198 ).toBe(0);
1199 });
1200
1201 it.each([true, false])(
1202 "returns the previous map when contents are unchanged (rollback supported: %s)",
1203 (supportsConversationRollback) => {
1204 const input = {
1205 supportsConversationRollback,
1206 timelineEntries,
1207 turnDiffSummaryByAssistantMessageId,
1208 inferredCheckpointTurnCountByTurnId: {},
1209 };
1210 const previous = buildRevertTurnCountByUserMessageId(input);
1211 const streamed = timelineEntries.map((entry) =>
1212 entry.message.role === "assistant"
1213 ? { ...entry, message: { ...entry.message, text: "Updated the file again" } }
1214 : entry,
1215 );
1216
1217 expect(
1218 buildRevertTurnCountByUserMessageId({ ...input, timelineEntries: streamed }, previous),
1219 ).toBe(previous);
1220 },
1221 );
1222
1223 it("returns a new map when a revert target changes", () => {
1224 const input = {
1225 supportsConversationRollback: true,
1226 timelineEntries,
1227 turnDiffSummaryByAssistantMessageId,
1228 inferredCheckpointTurnCountByTurnId: {},
1229 };
1230 const previous = buildRevertTurnCountByUserMessageId(input);
1231 const next = buildRevertTurnCountByUserMessageId(
1232 {
1233 ...input,
1234 turnDiffSummaryByAssistantMessageId: new Map([
1235 [
1236 assistantMessageId,
1237 {
1238 ...turnDiffSummaryByAssistantMessageId.get(assistantMessageId)!,
1239 checkpointTurnCount: 3,
1240 },
1241 ],
1242 ]),
1243 },
1244 previous,
1245 );
1246
1247 expect(next).not.toBe(previous);
1248 expect(next).toEqual(new Map([[userMessageId, 2]]));
1249 });
1250});
1251
12521127describe("deriveComposerSendState", () => {
12531128 it("treats expired terminal pills as non-sendable content", () => {
12541129 const state = deriveComposerSendState({
apps/web/src/components/ChatView.logic.ts-48
......@@ -38,7 +38,6 @@ import {
3838} from "../types";
3939import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
4040import * as Schema from "effect/Schema";
41import { shallow } from "zustand/vanilla/shallow";
4241import { appAtomRegistry } from "../rpc/atomRegistry";
4342import { environmentThreadDetails } from "../state/threads";
4443import {
......@@ -465,53 +464,6 @@ export function getAntigravitySendBlockReason(
465464 return null;
466465}
467466
468/**
469 * Maps each user message to the checkpoint turn count a revert should target.
470 * Returns `previous` when the result is unchanged: streaming text deltas
471 * rebuild `timelineEntries` per token, and the timeline row projection only
472 * reuses rows while this Map keeps its identity.
473 */
474export function buildRevertTurnCountByUserMessageId(
475 input: {
476 supportsConversationRollback: boolean;
477 timelineEntries: ReadonlyArray<TimelineEntry>;
478 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
479 inferredCheckpointTurnCountByTurnId: Readonly<Record<string, number | undefined>>;
480 },
481 previous: Map<MessageId, number> | null = null,
482): Map<MessageId, number> {
483 const byUserMessageId = new Map<MessageId, number>();
484 const entryCount = input.supportsConversationRollback ? input.timelineEntries.length : 0;
485 for (let index = 0; index < entryCount; index += 1) {
486 const entry = input.timelineEntries[index];
487 if (!entry || entry.kind !== "message" || entry.message.role !== "user") {
488 continue;
489 }
490
491 for (let nextIndex = index + 1; nextIndex < input.timelineEntries.length; nextIndex += 1) {
492 const nextEntry = input.timelineEntries[nextIndex];
493 if (!nextEntry || nextEntry.kind !== "message") {
494 continue;
495 }
496 if (nextEntry.message.role === "user") {
497 break;
498 }
499 const summary = input.turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id);
500 if (!summary) {
501 continue;
502 }
503 const turnCount =
504 summary.checkpointTurnCount ?? input.inferredCheckpointTurnCountByTurnId[summary.turnId];
505 if (typeof turnCount !== "number") {
506 break;
507 }
508 byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1));
509 break;
510 }
511 }
512 return previous !== null && shallow(previous, byUserMessageId) ? previous : byUserMessageId;
513}
514
515467export function reconcileMountedTerminalThreadIds(input: {
516468 currentThreadIds: ReadonlyArray<string>;
517469 openThreadIds: ReadonlyArray<string>;
apps/web/src/components/ChatView.tsx+6-44
......@@ -153,11 +153,9 @@ import {
153153 isImageAttachment,
154154 type SessionPhase,
155155 type Thread,
156 type TurnDiffSummary,
157156} from "../types";
158157import { useTheme } from "../hooks/useTheme";
159158import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
160import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
161159import { isCommandPaletteOpen } from "../commandPaletteBus";
162160import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
163161import { useMediaQuery } from "../hooks/useMediaQuery";
......@@ -367,7 +365,6 @@ import {
367365 buildExpiredTerminalContextToastCopy,
368366 buildLocalDraftThread,
369367 buildLoadingThreadFromShell,
370 buildRevertTurnCountByUserMessageId,
371368 buildThreadTurnInterruptInput,
372369 collectUserMessageBlobPreviewUrls,
373370 createLocalDispatchSnapshot,
......@@ -3101,35 +3098,6 @@ export default function ChatView(props: ChatViewProps) {
31013098 attachDraftHeroComposerAnchorRef,
31023099 captureDraftHeroComposerRect,
31033100 ] = useDraftHeroLayoutTransition(isDraftHeroState);
3104 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
3105 useTurnDiffSummaries(activeThread);
3106 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
3107 const byMessageId = new Map<MessageId, TurnDiffSummary>();
3108 for (const summary of turnDiffSummaries) {
3109 if (!summary.assistantMessageId) continue;
3110 byMessageId.set(summary.assistantMessageId, summary);
3111 }
3112 return byMessageId;
3113 }, [turnDiffSummaries]);
3114 const lastRevertTurnCountRef = useRef<Map<MessageId, number> | null>(null);
3115 const revertTurnCountByUserMessageId = useMemo(() => {
3116 const next = buildRevertTurnCountByUserMessageId(
3117 {
3118 supportsConversationRollback,
3119 timelineEntries,
3120 turnDiffSummaryByAssistantMessageId,
3121 inferredCheckpointTurnCountByTurnId,
3122 },
3123 lastRevertTurnCountRef.current,
3124 );
3125 lastRevertTurnCountRef.current = next;
3126 return next;
3127 }, [
3128 supportsConversationRollback,
3129 inferredCheckpointTurnCountByTurnId,
3130 timelineEntries,
3131 turnDiffSummaryByAssistantMessageId,
3132 ]);
31333101
31343102 const gitCwd = activeProject
31353103 ? projectScriptCwd({
......@@ -7778,17 +7746,11 @@ export default function ChatView(props: ChatViewProps) {
77787746 },
77797747 [activeThreadRef, isServerThread, onDiffPanelOpen],
77807748 );
7781 // Both the Map and the revert handler are read from refs at call-time so
7782 // the callback reference is fully stable and never busts context identity.
7783 const revertTurnCountRef = useRef(revertTurnCountByUserMessageId);
7784 revertTurnCountRef.current = revertTurnCountByUserMessageId;
7749 // The revert handler is read from a ref at call-time so the callback
7750 // reference is fully stable and never busts TimelineRowCtx identity.
77857751 const onRevertToTurnCountRef = useRef(onRevertToTurnCount);
77867752 onRevertToTurnCountRef.current = onRevertToTurnCount;
7787 const onRevertUserMessage = useCallback((messageId: MessageId) => {
7788 const targetTurnCount = revertTurnCountRef.current.get(messageId);
7789 if (typeof targetTurnCount !== "number") {
7790 return;
7791 }
7753 const onRevertTimelineTurn = useCallback((targetTurnCount: number) => {
77927754 void onRevertToTurnCountRef.current(targetTurnCount);
77937755 }, []);
77947756
......@@ -8104,12 +8066,12 @@ export default function ChatView(props: ChatViewProps) {
81048066 timelineEntries={timelineEntries}
81058067 latestTurn={activeLatestTurn}
81068068 runningTurnId={activeRunningTurnId}
8107 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
8069 turnDiffSummaries={activeThread.checkpoints}
81088070 activeThreadEnvironmentId={activeThread.environmentId}
81098071 routeThreadKey={routeThreadKey}
81108072 onOpenTurnDiff={onOpenTurnDiff}
8111 revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
8112 onRevertUserMessage={onRevertUserMessage}
8073 supportsConversationRollback={supportsConversationRollback}
8074 onRevertToTurnCount={onRevertTimelineTurn}
81138075 onUseArtifactTemplate={useArtifactTemplate}
81148076 isRevertingCheckpoint={isRevertingCheckpoint}
81158077 onImageExpand={onExpandTimelineImage}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+330-113
......@@ -1,5 +1,22 @@
11import { describe, expect, it } from "vite-plus/test";
2import { CheckpointRef, MessageId, TurnId } from "@t3tools/contracts";
2import {
3 CheckpointRef,
4 EnvironmentId,
5 EventId,
6 MessageId,
7 ProjectId,
8 ProviderInstanceId,
9 ThreadId,
10 TurnId,
11 type OrchestrationThread,
12} from "@t3tools/contracts";
13import {
14 applyThreadDetailEvent,
15 createEnvironmentThreadDetailAtoms,
16 EMPTY_ENVIRONMENT_THREAD_STATE,
17} from "@t3tools/client-runtime/state/threads";
18import * as Option from "effect/Option";
19import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
320import {
421 computeStableMessagesTimelineRows,
522 computeMessageDurationStart,
......@@ -12,6 +29,7 @@ import {
1229 shouldFollowWorkGroupAppend,
1330 shouldPreserveAssistantLineBreaks,
1431 type MessagesTimelineRow,
32 type MessagesTimelineRowsProjection,
1533 workEntryDisplayLabel,
1634} from "./MessagesTimeline.logic";
1735import {
......@@ -19,8 +37,8 @@ import {
1937 deriveTimelineEntries,
2038 deriveTimelineEntriesWithState,
2139 type WorkLogEntry,
40 type TimelineEntriesProjection,
2241} from "../../session-logic";
23import { buildRevertTurnCountByUserMessageId } from "../ChatView.logic";
2442import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types";
2543
2644describe("streaming row projection", () => {
......@@ -97,8 +115,8 @@ describe("streaming row projection", () => {
97115 runningTurnId: turnId,
98116 isWorking: true,
99117 activeTurnStartedAt: time(5),
100 turnDiffSummaryByAssistantMessageId: new Map<MessageId, TurnDiffSummary>(),
101 revertTurnCountByUserMessageId: new Map<MessageId, number>(),
118 turnDiffSummaries: [],
119 supportsConversationRollback: false,
102120 } satisfies Parameters<typeof deriveMessagesTimelineRows>[0];
103121 return { messages, work, timeline, input, time, turnId, historyTurnId };
104122 }
......@@ -243,52 +261,54 @@ describe("streaming row projection", () => {
243261 },
244262 );
245263
246 it("reuses rows when the revert map is rebuilt from the streamed entries", () => {
264 it("owns checkpoint lookups across streaming and equal source snapshots", () => {
247265 const initial = fixture("Partial");
248 const inferredCheckpointTurnCountByTurnId = { [initial.historyTurnId]: 1 };
249 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>([
250 [
251 MessageId.make("history-assistant"),
252 {
253 turnId: initial.historyTurnId,
254 checkpointTurnCount: 1,
255 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"),
256 status: "ready",
257 files: [],
258 assistantMessageId: MessageId.make("history-assistant"),
259 completedAt: initial.time(4),
260 },
261 ],
262 ]);
263 let revertMap: Map<MessageId, number> | null = null;
264 // Mirrors ChatView: the map is derived from each delta's entries.
265 const build = (timelineEntries: typeof initial.timeline.entries) => {
266 revertMap = buildRevertTurnCountByUserMessageId(
267 {
268 supportsConversationRollback: true,
269 timelineEntries,
270 turnDiffSummaryByAssistantMessageId,
271 inferredCheckpointTurnCountByTurnId,
272 },
273 revertMap,
274 );
275 return {
276 ...initial.input,
277 timelineEntries,
278 turnDiffSummaryByAssistantMessageId,
279 revertTurnCountByUserMessageId: revertMap,
280 };
266 let checkpointLookupReads = 0;
267 const summary: TurnDiffSummary = {
268 turnId: initial.historyTurnId,
269 get checkpointTurnCount() {
270 checkpointLookupReads += 1;
271 return 1;
272 },
273 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"),
274 status: "ready",
275 files: [],
276 get assistantMessageId() {
277 checkpointLookupReads += 1;
278 return MessageId.make("history-assistant");
279 },
280 get completedAt() {
281 checkpointLookupReads += 1;
282 return initial.time(4);
283 },
281284 };
282 const previous = deriveMessagesTimelineRowsWithState(build(initial.timeline.entries));
285 const input = {
286 ...initial.input,
287 turnDiffSummaries: [summary],
288 supportsConversationRollback: true,
289 expandedTurnIds: new Set([initial.historyTurnId]),
290 expandedWorkGroupIds: new Set<string>(),
291 };
292 const previous = deriveMessagesTimelineRowsWithState(input);
293 expect(checkpointLookupReads).toBeGreaterThan(0);
283294 expect(previous.rows.some((row) => row.kind === "message" && row.revertTurnCount === 0)).toBe(
284295 true,
285296 );
286297 const last = initial.messages.at(-1)!;
287298 const messages = [...initial.messages.slice(0, -1), { ...last, text: "Partial token" }];
288299 const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline);
289 const next = deriveMessagesTimelineRowsWithState(build(timeline.entries), previous);
290
291 expect(next.rows).toEqual(deriveMessagesTimelineRows(build(timeline.entries)));
300 const nextInput = {
301 ...input,
302 timelineEntries: timeline.entries,
303 turnDiffSummaries: [...input.turnDiffSummaries],
304 latestTurn: { ...input.latestTurn },
305 expandedTurnIds: new Set(input.expandedTurnIds),
306 expandedWorkGroupIds: new Set(input.expandedWorkGroupIds),
307 };
308 checkpointLookupReads = 0;
309 const next = deriveMessagesTimelineRowsWithState(nextInput, previous);
310 expect(checkpointLookupReads).toBe(0);
311 expect(next.rows).toEqual(deriveMessagesTimelineRows(nextInput));
292312 for (const [index, row] of previous.rows.entries()) {
293313 if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) {
294314 expect(next.rows[index]).toMatchObject({ message: { text: "Partial token" } });
......@@ -296,6 +316,205 @@ describe("streaming row projection", () => {
296316 expect(next.rows[index]).toBe(row);
297317 }
298318 }
319
320 const changed = deriveMessagesTimelineRowsWithState(
321 { ...nextInput, turnDiffSummaries: [{ ...summary, checkpointTurnCount: 3 }] },
322 next,
323 );
324 expect(
325 changed.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id),
326 ).toMatchObject({ revertTurnCount: 2 });
327 const unsupported = deriveMessagesTimelineRowsWithState(
328 { ...changed.input, supportsConversationRollback: false },
329 changed,
330 );
331 expect(
332 unsupported.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id),
333 ).toMatchObject({ revertTurnCount: undefined });
334 expect(
335 previous.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id),
336 ).toMatchObject({ revertTurnCount: 0 });
337 });
338
339 it("reuses long-thread rows through detail events, selectors, and attachment previews", () => {
340 const initial = fixture("Partial");
341 let checkpointLookupReads = 0;
342 const history = Array.from({ length: 250 }, (_, index) => {
343 const turnId = TurnId.make(`older-turn-${index}`);
344 const user = {
345 ...initial.messages[0]!,
346 id: MessageId.make(`older-user-${index}`),
347 createdAt: new Date(Date.UTC(2026, 8, 3, 0, 0, index * 5)).toISOString(),
348 attachments: [
349 {
350 type: "image" as const,
351 id: "image",
352 name: "image.png",
353 mimeType: "image/png",
354 sizeBytes: 42,
355 },
356 ],
357 };
358 const assistant = {
359 ...initial.messages[1]!,
360 id: MessageId.make(`older-assistant-${index}`),
361 turnId,
362 createdAt: new Date(Date.UTC(2026, 8, 3, 0, 0, index * 5 + 3)).toISOString(),
363 };
364 const checkpoint: TurnDiffSummary = {
365 turnId,
366 get checkpointTurnCount() {
367 checkpointLookupReads += 1;
368 return index + 1;
369 },
370 checkpointRef: CheckpointRef.make(`refs/t3/checkpoints/older-${index}`),
371 status: "ready",
372 files: [],
373 get assistantMessageId() {
374 checkpointLookupReads += 1;
375 return assistant.id;
376 },
377 get completedAt() {
378 checkpointLookupReads += 1;
379 return assistant.createdAt;
380 },
381 };
382 return { user, assistant, checkpoint };
383 });
384 const liveMessage = initial.messages.at(-1)!;
385 let thread: OrchestrationThread = {
386 id: ThreadId.make("streaming-thread"),
387 projectId: ProjectId.make("project"),
388 title: "Long thread",
389 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
390 runtimeMode: "full-access",
391 interactionMode: "default",
392 branch: null,
393 worktreePath: null,
394 latestTurn: {
395 ...initial.input.latestTurn,
396 requestedAt: initial.time(5),
397 assistantMessageId: liveMessage.id,
398 },
399 createdAt: initial.time(0),
400 updatedAt: initial.time(7),
401 archivedAt: null,
402 settledOverride: null,
403 settledAt: null,
404 deletedAt: null,
405 messages: [
406 ...history.flatMap(({ user, assistant }) => [user, assistant]),
407 ...initial.messages,
408 ],
409 proposedPlans: [],
410 activities: [],
411 checkpoints: history.map(({ checkpoint }) => checkpoint),
412 session: null,
413 };
414 const state = Atom.make(
415 AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(thread) }),
416 );
417 const details = createEnvironmentThreadDetailAtoms(() => state);
418 const ref = { environmentId: EnvironmentId.make("local"), threadId: thread.id };
419 const registry = AtomRegistry.make();
420 const unmount = registry.mount(details.detailAtom(ref));
421 const preview = createMessageAttachmentPreviewProjector();
422 let imageUrl = "https://first.test/image";
423 let timeline: TimelineEntriesProjection | null = null;
424 let projection: MessagesTimelineRowsProjection | null = null;
425 const project = () => {
426 const selected = registry.get(details.detailAtom(ref));
427 if (selected === null) throw new Error("Missing thread detail");
428 const messages = selected.messages.map((message) => preview(message, () => imageUrl));
429 timeline = deriveTimelineEntriesWithState(
430 messages,
431 selected.proposedPlans,
432 initial.work,
433 timeline,
434 );
435 projection = deriveMessagesTimelineRowsWithState(
436 {
437 timelineEntries: timeline.entries,
438 latestTurn: selected.latestTurn,
439 runningTurnId:
440 selected.latestTurn?.state === "running" ? selected.latestTurn.turnId : null,
441 isWorking: selected.latestTurn?.state === "running",
442 activeTurnStartedAt: selected.latestTurn?.startedAt ?? null,
443 turnDiffSummaries: selected.checkpoints,
444 supportsConversationRollback: true,
445 },
446 projection,
447 );
448 return projection;
449 };
450 const send = (text: string, sequence: number, streaming = true) => {
451 const result = applyThreadDetailEvent(thread, {
452 eventId: EventId.make(`delta-${sequence}`),
453 sequence,
454 commandId: null,
455 causationEventId: null,
456 correlationId: null,
457 metadata: {},
458 occurredAt: initial.time(8 + sequence),
459 aggregateKind: "thread",
460 aggregateId: thread.id,
461 type: "thread.message-sent",
462 payload: {
463 threadId: thread.id,
464 messageId: liveMessage.id,
465 role: "assistant",
466 text,
467 turnId: initial.turnId,
468 streaming,
469 createdAt: liveMessage.createdAt,
470 updatedAt: initial.time(8 + sequence),
471 },
472 });
473 if (result.kind !== "updated") throw new Error("Message event did not update the thread");
474 thread = result.thread;
475 registry.set(
476 state,
477 AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(thread) }),
478 );
479 return project();
480 };
481
482 try {
483 const first = project();
484 const saved = structuredClone(first.rows);
485 expect(checkpointLookupReads).toBeGreaterThan(0);
486 checkpointLookupReads = 0;
487 for (let index = 0; index < 10; index += 1) {
488 const next = send(` ${index}`, index + 1);
489 for (const [rowIndex, row] of first.rows.entries()) {
490 if (
491 (row.kind === "message" || row.kind === "assistant-meta") &&
492 row.message.id === liveMessage.id
493 )
494 continue;
495 expect(next.rows[rowIndex]).toBe(row);
496 }
497 }
498 expect(checkpointLookupReads).toBe(0);
499 const streamed = project();
500 expect(streamed.rows).toEqual(deriveMessagesTimelineRows(streamed.input));
501
502 imageUrl = "https://renewed.test/image";
503 const renewed = project();
504 expect(renewed.rows[0]).not.toBe(first.rows[0]);
505 expect(renewed.rows[0]).toMatchObject({
506 message: { attachments: [{ previewUrl: imageUrl }] },
507 });
508 const completed = send("Complete", 11, false);
509 expect(completed.rows).toEqual(deriveMessagesTimelineRows(completed.input));
510 expect(
511 completed.rows.find((row) => row.kind === "message" && row.message.id === liveMessage.id),
512 ).toMatchObject({ message: { text: "Complete" }, assistantCopyStreaming: false });
513 expect(first.rows).toEqual(saved);
514 } finally {
515 unmount();
516 registry.dispose();
517 }
299518 });
300519
301520 it.each(["completion", "turn", "role", "ordering"] as const)(
......@@ -407,7 +626,7 @@ describe("streaming row projection", () => {
407626 ? { ...message, role: "user", turnId: null, createdAt: initial.time(0) }
408627 : message,
409628 );
410 check({ revertTurnCountByUserMessageId: new Map([[MessageId.make("live-user"), 3]]) });
629 check({ supportsConversationRollback: true });
411630 });
412631});
413632
......@@ -574,8 +793,8 @@ describe("work entry labels", () => {
574793 ],
575794 isWorking: false,
576795 activeTurnStartedAt: null,
577 turnDiffSummaryByAssistantMessageId: new Map(),
578 revertTurnCountByUserMessageId: new Map(),
796 turnDiffSummaries: [],
797 supportsConversationRollback: false,
579798 });
580799 const directRow = rows.find((row) => row.kind === "work");
581800 expect(directRow).toMatchObject({
......@@ -887,8 +1106,8 @@ describe("deriveMessagesTimelineRows", () => {
8871106 ],
8881107 isWorking: false,
8891108 activeTurnStartedAt: null,
890 turnDiffSummaryByAssistantMessageId: new Map(),
891 revertTurnCountByUserMessageId: new Map(),
1109 turnDiffSummaries: [],
1110 supportsConversationRollback: false,
8921111 });
8931112
8941113 expect(rows).toEqual([
......@@ -950,8 +1169,8 @@ describe("deriveMessagesTimelineRows", () => {
9501169 expandedTurnIds: new Set(["turn-1" as never]),
9511170 isWorking: false,
9521171 activeTurnStartedAt: null,
953 turnDiffSummaryByAssistantMessageId: new Map(),
954 revertTurnCountByUserMessageId: new Map(),
1172 turnDiffSummaries: [],
1173 supportsConversationRollback: false,
9551174 });
9561175
9571176 const assistantRows = rows.filter(
......@@ -1004,8 +1223,8 @@ describe("deriveMessagesTimelineRows", () => {
10041223 },
10051224 isWorking: false,
10061225 activeTurnStartedAt: null,
1007 turnDiffSummaryByAssistantMessageId: new Map(),
1008 revertTurnCountByUserMessageId: new Map(),
1226 turnDiffSummaries: [],
1227 supportsConversationRollback: false,
10091228 });
10101229
10111230 const assistantRows = rows.filter(
......@@ -1061,10 +1280,8 @@ describe("deriveMessagesTimelineRows", () => {
10611280 ],
10621281 isWorking: false,
10631282 activeTurnStartedAt: null,
1064 turnDiffSummaryByAssistantMessageId: new Map([
1065 ["assistant-1" as never, assistantTurnDiffSummary],
1066 ]),
1067 revertTurnCountByUserMessageId: new Map([["user-1" as never, 1]]),
1283 turnDiffSummaries: [assistantTurnDiffSummary],
1284 supportsConversationRollback: true,
10681285 });
10691286
10701287 const userRow = rows.find(
......@@ -1142,8 +1359,8 @@ describe("deriveMessagesTimelineRows", () => {
11421359 timelineEntries,
11431360 isWorking: false,
11441361 activeTurnStartedAt: null,
1145 turnDiffSummaryByAssistantMessageId: new Map(),
1146 revertTurnCountByUserMessageId: new Map(),
1362 turnDiffSummaries: [],
1363 supportsConversationRollback: false,
11471364 });
11481365
11491366 const foldRow = collapsedRows.find(
......@@ -1165,8 +1382,8 @@ describe("deriveMessagesTimelineRows", () => {
11651382 expandedTurnIds: new Set(["turn-1" as never]),
11661383 isWorking: false,
11671384 activeTurnStartedAt: null,
1168 turnDiffSummaryByAssistantMessageId: new Map(),
1169 revertTurnCountByUserMessageId: new Map(),
1385 turnDiffSummaries: [],
1386 supportsConversationRollback: false,
11701387 });
11711388
11721389 expect(expandedRows.map((row) => row.id)).toEqual([
......@@ -1235,8 +1452,8 @@ describe("deriveMessagesTimelineRows", () => {
12351452 },
12361453 isWorking: false,
12371454 activeTurnStartedAt: null,
1238 turnDiffSummaryByAssistantMessageId: new Map(),
1239 revertTurnCountByUserMessageId: new Map(),
1455 turnDiffSummaries: [],
1456 supportsConversationRollback: false,
12401457 };
12411458 const rows = deriveMessagesTimelineRows({ ...input, timelineEntries });
12421459
......@@ -1318,8 +1535,8 @@ describe("deriveMessagesTimelineRows", () => {
13181535 timelineEntries,
13191536 isWorking: false,
13201537 activeTurnStartedAt: null,
1321 turnDiffSummaryByAssistantMessageId: new Map(),
1322 revertTurnCountByUserMessageId: new Map(),
1538 turnDiffSummaries: [],
1539 supportsConversationRollback: false,
13231540 });
13241541
13251542 expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]);
......@@ -1421,8 +1638,8 @@ describe("deriveMessagesTimelineRows", () => {
14211638 },
14221639 isWorking: true,
14231640 activeTurnStartedAt: "2026-01-01T00:00:14Z",
1424 turnDiffSummaryByAssistantMessageId: new Map(),
1425 revertTurnCountByUserMessageId: new Map(),
1641 turnDiffSummaries: [],
1642 supportsConversationRollback: false,
14261643 });
14271644
14281645 const foldRow = rows.find(
......@@ -1458,8 +1675,8 @@ describe("deriveMessagesTimelineRows", () => {
14581675 },
14591676 isWorking: false,
14601677 activeTurnStartedAt: null,
1461 turnDiffSummaryByAssistantMessageId: new Map(),
1462 revertTurnCountByUserMessageId: new Map(),
1678 turnDiffSummaries: [],
1679 supportsConversationRollback: false,
14631680 });
14641681
14651682 expect(rows).toEqual([
......@@ -1526,8 +1743,8 @@ describe("deriveMessagesTimelineRows", () => {
15261743 },
15271744 isWorking: true,
15281745 activeTurnStartedAt: "2026-01-01T00:01:00Z",
1529 turnDiffSummaryByAssistantMessageId: new Map(),
1530 revertTurnCountByUserMessageId: new Map(),
1746 turnDiffSummaries: [],
1747 supportsConversationRollback: false,
15311748 });
15321749
15331750 expect(rows.map((row) => row.id)).toEqual([
......@@ -1580,8 +1797,8 @@ describe("deriveMessagesTimelineRows", () => {
15801797 },
15811798 isWorking: true,
15821799 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1583 turnDiffSummaryByAssistantMessageId: new Map(),
1584 revertTurnCountByUserMessageId: new Map(),
1800 turnDiffSummaries: [],
1801 supportsConversationRollback: false,
15851802 });
15861803
15871804 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
......@@ -1674,8 +1891,8 @@ describe("deriveMessagesTimelineRows", () => {
16741891 },
16751892 isWorking: true,
16761893 activeTurnStartedAt: "2026-01-01T00:01:00Z",
1677 turnDiffSummaryByAssistantMessageId: new Map(),
1678 revertTurnCountByUserMessageId: new Map(),
1894 turnDiffSummaries: [],
1895 supportsConversationRollback: false,
16791896 });
16801897
16811898 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
......@@ -1754,8 +1971,8 @@ describe("deriveMessagesTimelineRows", () => {
17541971 },
17551972 isWorking: true,
17561973 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1757 turnDiffSummaryByAssistantMessageId: new Map(),
1758 revertTurnCountByUserMessageId: new Map(),
1974 turnDiffSummaries: [],
1975 supportsConversationRollback: false,
17591976 });
17601977
17611978 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
......@@ -1827,8 +2044,8 @@ describe("deriveMessagesTimelineRows", () => {
18272044 },
18282045 isWorking: true,
18292046 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1830 turnDiffSummaryByAssistantMessageId: new Map(),
1831 revertTurnCountByUserMessageId: new Map(),
2047 turnDiffSummaries: [],
2048 supportsConversationRollback: false,
18322049 });
18332050
18342051 expect(rows.map((row) => row.kind)).toEqual(["working", "work", "message", "work-live"]);
......@@ -1879,8 +2096,8 @@ describe("deriveMessagesTimelineRows", () => {
18792096 expandedTurnIds: new Set([turnId]),
18802097 isWorking: false,
18812098 activeTurnStartedAt: null,
1882 turnDiffSummaryByAssistantMessageId: new Map(),
1883 revertTurnCountByUserMessageId: new Map(),
2099 turnDiffSummaries: [],
2100 supportsConversationRollback: false,
18842101 });
18852102
18862103 expect(rows.find((row) => row.kind === "work")).toMatchObject({
......@@ -1947,8 +2164,8 @@ describe("deriveMessagesTimelineRows", () => {
19472164 },
19482165 isWorking: true,
19492166 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1950 turnDiffSummaryByAssistantMessageId: new Map(),
1951 revertTurnCountByUserMessageId: new Map(),
2167 turnDiffSummaries: [],
2168 supportsConversationRollback: false,
19522169 });
19532170
19542171 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]);
......@@ -1994,8 +2211,8 @@ describe("deriveMessagesTimelineRows", () => {
19942211 latestTurn: null,
19952212 isWorking: true,
19962213 activeTurnStartedAt: "2026-01-01T00:01:00Z",
1997 turnDiffSummaryByAssistantMessageId: new Map(),
1998 revertTurnCountByUserMessageId: new Map(),
2214 turnDiffSummaries: [],
2215 supportsConversationRollback: false,
19992216 });
20002217
20012218 expect(rows.some((row) => row.kind === "work-live")).toBe(false);
......@@ -2055,8 +2272,8 @@ describe("deriveMessagesTimelineRows", () => {
20552272 },
20562273 isWorking: true,
20572274 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2058 turnDiffSummaryByAssistantMessageId: new Map(),
2059 revertTurnCountByUserMessageId: new Map(),
2275 turnDiffSummaries: [],
2276 supportsConversationRollback: false,
20602277 });
20612278
20622279 expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([
......@@ -2100,8 +2317,8 @@ describe("deriveMessagesTimelineRows", () => {
21002317 },
21012318 isWorking: true,
21022319 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2103 turnDiffSummaryByAssistantMessageId: new Map(),
2104 revertTurnCountByUserMessageId: new Map(),
2320 turnDiffSummaries: [],
2321 supportsConversationRollback: false,
21052322 });
21062323
21072324 const workLiveRow = rows.find((row) => row.kind === "work-live");
......@@ -2148,8 +2365,8 @@ describe("deriveMessagesTimelineRows", () => {
21482365 },
21492366 isWorking: true,
21502367 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2151 turnDiffSummaryByAssistantMessageId: new Map(),
2152 revertTurnCountByUserMessageId: new Map(),
2368 turnDiffSummaries: [],
2369 supportsConversationRollback: false,
21532370 });
21542371
21552372 const initialRows = deriveRows(null);
......@@ -2226,8 +2443,8 @@ describe("deriveMessagesTimelineRows", () => {
22262443 runningTurnId: "turn-2" as never,
22272444 isWorking: true,
22282445 activeTurnStartedAt: "2026-01-01T00:01:00Z",
2229 turnDiffSummaryByAssistantMessageId: new Map(),
2230 revertTurnCountByUserMessageId: new Map(),
2446 turnDiffSummaries: [],
2447 supportsConversationRollback: false,
22312448 });
22322449
22332450 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
......@@ -2271,8 +2488,8 @@ describe("deriveMessagesTimelineRows", () => {
22712488 expandedTurnIds: new Set(["turn-1" as never]),
22722489 isWorking: false,
22732490 activeTurnStartedAt: null,
2274 turnDiffSummaryByAssistantMessageId: new Map(),
2275 revertTurnCountByUserMessageId: new Map(),
2491 turnDiffSummaries: [],
2492 supportsConversationRollback: false,
22762493 });
22772494
22782495 const assistantRows = rows.filter(
......@@ -2309,8 +2526,8 @@ describe("deriveMessagesTimelineRows", () => {
23092526 },
23102527 isWorking: true,
23112528 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2312 turnDiffSummaryByAssistantMessageId: new Map(),
2313 revertTurnCountByUserMessageId: new Map(),
2529 turnDiffSummaries: [],
2530 supportsConversationRollback: false,
23142531 });
23152532
23162533 const assistantRow = rows.find(
......@@ -2373,8 +2590,8 @@ describe("deriveMessagesTimelineRows", () => {
23732590 timelineEntries,
23742591 isWorking: false,
23752592 activeTurnStartedAt: null,
2376 turnDiffSummaryByAssistantMessageId: new Map(),
2377 revertTurnCountByUserMessageId: new Map(),
2593 turnDiffSummaries: [],
2594 supportsConversationRollback: false,
23782595 };
23792596 const collapsedRows = deriveMessagesTimelineRows(baseInput);
23802597 const expandedRows = deriveMessagesTimelineRows({
......@@ -2467,8 +2684,8 @@ describe("deriveMessagesTimelineRows", () => {
24672684 timelineEntries,
24682685 isWorking: false,
24692686 activeTurnStartedAt: null,
2470 turnDiffSummaryByAssistantMessageId: new Map(),
2471 revertTurnCountByUserMessageId: new Map(),
2687 turnDiffSummaries: [],
2688 supportsConversationRollback: false,
24722689 });
24732690
24742691 expect(row).toMatchObject({
......@@ -2509,8 +2726,8 @@ describe("deriveMessagesTimelineRows", () => {
25092726 expandedTurnIds: new Set([turnId]),
25102727 runningTurnId: isWorking ? turnId : null,
25112728 activeTurnStartedAt: isWorking ? createdAt : null,
2512 turnDiffSummaryByAssistantMessageId: new Map(),
2513 revertTurnCountByUserMessageId: new Map(),
2729 turnDiffSummaries: [],
2730 supportsConversationRollback: false,
25142731 };
25152732 const expandedRows = deriveMessagesTimelineRows({
25162733 ...input,
......@@ -2549,8 +2766,8 @@ describe("deriveMessagesTimelineRows", () => {
25492766 timelineEntries,
25502767 isWorking: false,
25512768 activeTurnStartedAt: null,
2552 turnDiffSummaryByAssistantMessageId: new Map(),
2553 revertTurnCountByUserMessageId: new Map(),
2769 turnDiffSummaries: [],
2770 supportsConversationRollback: false,
25542771 });
25552772
25562773 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
......@@ -2596,8 +2813,8 @@ describe("deriveMessagesTimelineRows", () => {
25962813 timelineEntries,
25972814 isWorking: false,
25982815 activeTurnStartedAt: null,
2599 turnDiffSummaryByAssistantMessageId: new Map(),
2600 revertTurnCountByUserMessageId: new Map(),
2816 turnDiffSummaries: [],
2817 supportsConversationRollback: false,
26012818 });
26022819
26032820 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
......@@ -2653,8 +2870,8 @@ describe("computeStableMessagesTimelineRows", () => {
26532870 runningTurnId: turnId,
26542871 isWorking: true,
26552872 activeTurnStartedAt: startedAt,
2656 turnDiffSummaryByAssistantMessageId: new Map(),
2657 revertTurnCountByUserMessageId: new Map(),
2873 turnDiffSummaries: [],
2874 supportsConversationRollback: false,
26582875 };
26592876 const assistantEntry = {
26602877 id: "assistant-entry",
......@@ -2731,8 +2948,8 @@ describe("computeStableMessagesTimelineRows", () => {
27312948 ],
27322949 isWorking: false,
27332950 activeTurnStartedAt: null,
2734 turnDiffSummaryByAssistantMessageId: new Map(),
2735 revertTurnCountByUserMessageId: new Map(),
2951 turnDiffSummaries: [],
2952 supportsConversationRollback: false,
27362953 });
27372954
27382955 const initial = computeStableMessagesTimelineRows(rows, {
......@@ -2780,8 +2997,8 @@ describe("computeStableMessagesTimelineRows", () => {
27802997 ],
27812998 isWorking: false,
27822999 activeTurnStartedAt: null,
2783 turnDiffSummaryByAssistantMessageId: new Map(),
2784 revertTurnCountByUserMessageId: new Map(),
3000 turnDiffSummaries: [],
3001 supportsConversationRollback: false,
27853002 });
27863003
27873004 const firstRows = createRows();
......@@ -2836,8 +3053,8 @@ describe("computeStableMessagesTimelineRows", () => {
28363053 ],
28373054 isWorking: false,
28383055 activeTurnStartedAt: null,
2839 turnDiffSummaryByAssistantMessageId: new Map(),
2840 revertTurnCountByUserMessageId: new Map(),
3056 turnDiffSummaries: [],
3057 supportsConversationRollback: false,
28413058 });
28423059
28433060 const initial = computeStableMessagesTimelineRows(firstRows, {
apps/web/src/components/chat/MessagesTimeline.logic.ts+82-7
......@@ -18,6 +18,7 @@ export {
1818} from "@t3tools/client-runtime/work-log/presentation";
1919import {
2020 formatDuration,
21 inferCheckpointTurnCountByTurnId,
2122 isStreamingMessageTextUpdate,
2223 workEntryDisplayIndicatesToolFailure,
2324 workEntryIndicatesToolSuccess,
......@@ -776,6 +777,45 @@ function attachTrailingToolGroupsToAssistant(
776777 return result;
777778}
778779
780/** Match each user message to the next assistant checkpoint. */
781function buildRevertTurnCountByUserMessageId(input: {
782 supportsConversationRollback: boolean;
783 timelineEntries: ReadonlyArray<TimelineEntry>;
784 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
785 inferredCheckpointTurnCountByTurnId: Readonly<Record<string, number | undefined>>;
786}): Map<MessageId, number> {
787 const byUserMessageId = new Map<MessageId, number>();
788 const entryCount = input.supportsConversationRollback ? input.timelineEntries.length : 0;
789 for (let index = 0; index < entryCount; index += 1) {
790 const entry = input.timelineEntries[index];
791 if (!entry || entry.kind !== "message" || entry.message.role !== "user") {
792 continue;
793 }
794
795 for (let nextIndex = index + 1; nextIndex < input.timelineEntries.length; nextIndex += 1) {
796 const nextEntry = input.timelineEntries[nextIndex];
797 if (!nextEntry || nextEntry.kind !== "message") {
798 continue;
799 }
800 if (nextEntry.message.role === "user") {
801 break;
802 }
803 const summary = input.turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id);
804 if (!summary) {
805 continue;
806 }
807 const turnCount =
808 summary.checkpointTurnCount ?? input.inferredCheckpointTurnCountByTurnId[summary.turnId];
809 if (typeof turnCount !== "number") {
810 break;
811 }
812 byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1));
813 break;
814 }
815 }
816 return byUserMessageId;
817}
818
779819export function deriveMessagesTimelineRows(input: {
780820 timelineEntries: ReadonlyArray<TimelineEntry>;
781821 latestTurn?: TimelineLatestTurn | null;
......@@ -784,9 +824,23 @@ export function deriveMessagesTimelineRows(input: {
784824 expandedWorkGroupIds?: ReadonlySet<string>;
785825 isWorking: boolean;
786826 activeTurnStartedAt: string | null;
787 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
788 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;
827 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
828 supportsConversationRollback: boolean;
789829}): MessagesTimelineRow[] {
830 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
831 for (const summary of input.turnDiffSummaries) {
832 if (summary.assistantMessageId) {
833 turnDiffSummaryByAssistantMessageId.set(summary.assistantMessageId, summary);
834 }
835 }
836 const revertTurnCountByUserMessageId = buildRevertTurnCountByUserMessageId({
837 supportsConversationRollback: input.supportsConversationRollback,
838 timelineEntries: input.timelineEntries,
839 turnDiffSummaryByAssistantMessageId,
840 inferredCheckpointTurnCountByTurnId: input.supportsConversationRollback
841 ? inferCheckpointTurnCountByTurnId(input.turnDiffSummaries)
842 : {},
843 });
790844 const nextRows: MessagesTimelineRow[] = [];
791845 const durationStartByMessageId = computeMessageDurationStart(
792846 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
......@@ -1134,11 +1188,11 @@ export function deriveMessagesTimelineRows(input: {
11341188 assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress,
11351189 assistantTurnDiffSummary:
11361190 timelineEntry.message.role === "assistant"
1137 ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)
1191 ? turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)
11381192 : undefined,
11391193 revertTurnCount:
11401194 timelineEntry.message.role === "user"
1141 ? input.revertTurnCountByUserMessageId.get(timelineEntry.message.id)
1195 ? revertTurnCountByUserMessageId.get(timelineEntry.message.id)
11421196 : undefined,
11431197 });
11441198 }
......@@ -1168,9 +1222,30 @@ function replaceStreamingMessageRows(
11681222 input: MessagesTimelineRowsInput,
11691223 previous: MessagesTimelineRowsProjection,
11701224): MessagesTimelineRow[] | null {
1171 const { timelineEntries: previousEntries, ...previousContext } = previous.input;
1172 const { timelineEntries, ...context } = input;
1173 if (timelineEntries.length !== previousEntries.length || !shallow(previousContext, context)) {
1225 const {
1226 timelineEntries: previousEntries,
1227 turnDiffSummaries: previousSummaries,
1228 latestTurn: previousLatestTurn,
1229 expandedTurnIds: previousExpandedTurns,
1230 expandedWorkGroupIds: previousExpandedGroups,
1231 ...previousContext
1232 } = previous.input;
1233 const {
1234 timelineEntries,
1235 turnDiffSummaries,
1236 latestTurn,
1237 expandedTurnIds,
1238 expandedWorkGroupIds,
1239 ...context
1240 } = input;
1241 if (
1242 timelineEntries.length !== previousEntries.length ||
1243 !shallow(previousContext, context) ||
1244 !shallow(previousSummaries, turnDiffSummaries) ||
1245 !shallow(previousLatestTurn, latestTurn) ||
1246 !shallow(previousExpandedTurns, expandedTurnIds) ||
1247 !shallow(previousExpandedGroups, expandedWorkGroupIds)
1248 ) {
11741249 return null;
11751250 }
11761251 const replacements = new Map<ChatMessage, ChatMessage>();
apps/web/src/components/chat/MessagesTimeline.test.tsx+14-19
......@@ -184,11 +184,11 @@ function buildProps() {
184184 listRef: createRef<LegendListRef | null>(),
185185 latestTurn: null,
186186 runningTurnId: null,
187 turnDiffSummaryByAssistantMessageId: new Map(),
187 turnDiffSummaries: [],
188188 routeThreadKey: "environment-local:thread-1",
189189 onOpenTurnDiff: () => {},
190 revertTurnCountByUserMessageId: new Map(),
191 onRevertUserMessage: () => {},
190 supportsConversationRollback: false,
191 onRevertToTurnCount: () => {},
192192 isRevertingCheckpoint: false,
193193 onImageExpand: () => {},
194194 activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
......@@ -448,22 +448,17 @@ describe("MessagesTimeline", () => {
448448 },
449449 },
450450 ]}
451 turnDiffSummaryByAssistantMessageId={
452 new Map([
453 [
454 assistantMessageId,
455 {
456 turnId,
457 checkpointTurnCount: 1,
458 checkpointRef: CheckpointRef.make("checkpoint-with-files"),
459 status: "ready",
460 files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],
461 assistantMessageId,
462 completedAt: MESSAGE_CREATED_AT,
463 },
464 ],
465 ])
466 }
451 turnDiffSummaries={[
452 {
453 turnId,
454 checkpointTurnCount: 1,
455 checkpointRef: CheckpointRef.make("checkpoint-with-files"),
456 status: "ready",
457 files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],
458 assistantMessageId,
459 completedAt: MESSAGE_CREATED_AT,
460 },
461 ]}
467462 />,
468463 );
469464
apps/web/src/components/chat/MessagesTimeline.tsx+19-17
......@@ -203,7 +203,7 @@ interface TimelineRowSharedState {
203203 workspaceRoot: string | undefined;
204204 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
205205 activeThreadEnvironmentId: EnvironmentId;
206 onRevertUserMessage: (messageId: MessageId) => void;
206 onRevertToTurnCount: (targetTurnCount: number) => void;
207207 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
208208 onImageExpand: (preview: ExpandedImagePreview) => void;
209209 onFileOpen: (attachment: ChatFileAttachment) => void;
......@@ -311,11 +311,11 @@ interface MessagesTimelineProps {
311311 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
312312 latestTurn: TimelineLatestTurn | null;
313313 runningTurnId: TurnId | null;
314 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;
314 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
315315 routeThreadKey: string;
316316 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
317 revertTurnCountByUserMessageId: Map<MessageId, number>;
318 onRevertUserMessage: (messageId: MessageId) => void;
317 supportsConversationRollback: boolean;
318 onRevertToTurnCount: (targetTurnCount: number) => void;
319319 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
320320 isRevertingCheckpoint: boolean;
321321 onImageExpand: (preview: ExpandedImagePreview) => void;
......@@ -369,11 +369,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
369369 timelineEntries,
370370 latestTurn,
371371 runningTurnId,
372 turnDiffSummaryByAssistantMessageId,
372 turnDiffSummaries,
373373 routeThreadKey,
374374 onOpenTurnDiff,
375 revertTurnCountByUserMessageId,
376 onRevertUserMessage,
375 supportsConversationRollback,
376 onRevertToTurnCount,
377377 onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,
378378 isRevertingCheckpoint,
379379 onImageExpand,
......@@ -543,8 +543,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
543543 expandedWorkGroupIds,
544544 isWorking,
545545 activeTurnStartedAt,
546 turnDiffSummaryByAssistantMessageId,
547 revertTurnCountByUserMessageId,
546 turnDiffSummaries,
547 supportsConversationRollback,
548548 },
549549 previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot
550550 ? previous.projection
......@@ -563,8 +563,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
563563 expandedWorkGroupIds,
564564 isWorking,
565565 activeTurnStartedAt,
566 turnDiffSummaryByAssistantMessageId,
567 revertTurnCountByUserMessageId,
566 turnDiffSummaries,
567 supportsConversationRollback,
568568 ]);
569569 const rows = useStableRows(rawRows);
570570 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
......@@ -729,7 +729,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
729729 workspaceRoot,
730730 skills,
731731 activeThreadEnvironmentId,
732 onRevertUserMessage,
732 onRevertToTurnCount,
733733 onUseArtifactTemplate,
734734 onImageExpand,
735735 onFileOpen,
......@@ -753,7 +753,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
753753 workspaceRoot,
754754 skills,
755755 activeThreadEnvironmentId,
756 onRevertUserMessage,
756 onRevertToTurnCount,
757757 onUseArtifactTemplate,
758758 onImageExpand,
759759 onFileOpen,
......@@ -1336,7 +1336,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
13361336 ];
13371337 const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-"));
13381338 const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-"));
1339 const canRevertAgentWork = typeof row.revertTurnCount === "number";
1339 const revertTurnCount = row.revertTurnCount;
13401340
13411341 return (
13421342 <div className="group flex flex-col items-end gap-1">
......@@ -1498,7 +1498,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
14981498 </TooltipPopup>
14991499 </Tooltip>
15001500 <div className="flex items-center gap-0.5">
1501 {canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}
1501 {typeof revertTurnCount === "number" && (
1502 <RevertUserMessageButton turnCount={revertTurnCount} />
1503 )}
15021504 {displayedUserMessage.copyText && (
15031505 <MessageCopyButton text={displayedUserMessage.copyText} variant="ghost" />
15041506 )}
......@@ -1509,7 +1511,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
15091511 );
15101512}
15111513
1512function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
1514function RevertUserMessageButton({ turnCount }: { turnCount: number }) {
15131515 const ctx = use(TimelineRowCtx);
15141516 const activity = use(TimelineRowActivityCtx);
15151517
......@@ -1522,7 +1524,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
15221524 size="xs"
15231525 variant="ghost"
15241526 disabled={activity.isRevertingCheckpoint || activity.isWorking}
1525 onClick={() => ctx.onRevertUserMessage(messageId)}
1527 onClick={() => ctx.onRevertToTurnCount(turnCount)}
15261528 aria-label="Revert to this message"
15271529 />
15281530 }