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 @@...@@ -1,6 +1,5 @@
1import {1import {
2 ANTIGRAVITY_DEFAULT_MODEL,2 ANTIGRAVITY_DEFAULT_MODEL,
3 CheckpointRef,
4 EnvironmentId,3 EnvironmentId,
5 MessageId,4 MessageId,
6 ProjectId,5 ProjectId,
...@@ -13,7 +12,6 @@ import {...@@ -13,7 +12,6 @@ import {
13import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";12import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1413
15import type { Thread, ThreadShell, TurnDiffSummary } from "../types";14import type { Thread, ThreadShell, TurnDiffSummary } from "../types";
16import type { TimelineEntry } from "../session-logic";
17import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";15import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
18import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";16import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
19import type { RightPanelSurface } from "../rightPanelStore";17import type { RightPanelSurface } from "../rightPanelStore";
...@@ -24,7 +22,6 @@ import {...@@ -24,7 +22,6 @@ import {
24 branchMismatchKey,22 branchMismatchKey,
25 buildExpiredTerminalContextToastCopy,23 buildExpiredTerminalContextToastCopy,
26 buildLoadingThreadFromShell,24 buildLoadingThreadFromShell,
27 buildRevertTurnCountByUserMessageId,
28 buildThreadTurnInterruptInput,25 buildThreadTurnInterruptInput,
29 createLocalDispatchSnapshot,26 createLocalDispatchSnapshot,
30 deriveComposerSendState,27 deriveComposerSendState,
...@@ -1127,128 +1124,6 @@ describe("resolveComposerInteractionMode", () => {...@@ -1127,128 +1124,6 @@ describe("resolveComposerInteractionMode", () => {
1127 });1124 });
1128});1125});
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
1252describe("deriveComposerSendState", () => {1127describe("deriveComposerSendState", () => {
1253 it("treats expired terminal pills as non-sendable content", () => {1128 it("treats expired terminal pills as non-sendable content", () => {
1254 const state = deriveComposerSendState({1129 const state = deriveComposerSendState({
apps/web/src/components/ChatView.logic.ts-48
...@@ -38,7 +38,6 @@ import {...@@ -38,7 +38,6 @@ import {
38} from "../types";38} from "../types";
39import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";39import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
40import * as Schema from "effect/Schema";40import * as Schema from "effect/Schema";
41import { shallow } from "zustand/vanilla/shallow";
42import { appAtomRegistry } from "../rpc/atomRegistry";41import { appAtomRegistry } from "../rpc/atomRegistry";
43import { environmentThreadDetails } from "../state/threads";42import { environmentThreadDetails } from "../state/threads";
44import {43import {
...@@ -465,53 +464,6 @@ export function getAntigravitySendBlockReason(...@@ -465,53 +464,6 @@ export function getAntigravitySendBlockReason(
465 return null;464 return null;
466}465}
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
515export function reconcileMountedTerminalThreadIds(input: {467export function reconcileMountedTerminalThreadIds(input: {
516 currentThreadIds: ReadonlyArray<string>;468 currentThreadIds: ReadonlyArray<string>;
517 openThreadIds: ReadonlyArray<string>;469 openThreadIds: ReadonlyArray<string>;
apps/web/src/components/ChatView.tsx+6-44
...@@ -153,11 +153,9 @@ import {...@@ -153,11 +153,9 @@ import {
153 isImageAttachment,153 isImageAttachment,
154 type SessionPhase,154 type SessionPhase,
155 type Thread,155 type Thread,
156 type TurnDiffSummary,
157} from "../types";156} from "../types";
158import { useTheme } from "../hooks/useTheme";157import { useTheme } from "../hooks/useTheme";
159import { writeTextToClipboard } from "../hooks/useCopyToClipboard";158import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
160import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
161import { isCommandPaletteOpen } from "../commandPaletteBus";159import { isCommandPaletteOpen } from "../commandPaletteBus";
162import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";160import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
163import { useMediaQuery } from "../hooks/useMediaQuery";161import { useMediaQuery } from "../hooks/useMediaQuery";
...@@ -367,7 +365,6 @@ import {...@@ -367,7 +365,6 @@ import {
367 buildExpiredTerminalContextToastCopy,365 buildExpiredTerminalContextToastCopy,
368 buildLocalDraftThread,366 buildLocalDraftThread,
369 buildLoadingThreadFromShell,367 buildLoadingThreadFromShell,
370 buildRevertTurnCountByUserMessageId,
371 buildThreadTurnInterruptInput,368 buildThreadTurnInterruptInput,
372 collectUserMessageBlobPreviewUrls,369 collectUserMessageBlobPreviewUrls,
373 createLocalDispatchSnapshot,370 createLocalDispatchSnapshot,
...@@ -3101,35 +3098,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -3101,35 +3098,6 @@ export default function ChatView(props: ChatViewProps) {
3101 attachDraftHeroComposerAnchorRef,3098 attachDraftHeroComposerAnchorRef,
3102 captureDraftHeroComposerRect,3099 captureDraftHeroComposerRect,
3103 ] = useDraftHeroLayoutTransition(isDraftHeroState);3100 ] = 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
3134 const gitCwd = activeProject3102 const gitCwd = activeProject
3135 ? projectScriptCwd({3103 ? projectScriptCwd({
...@@ -7778,17 +7746,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -7778,17 +7746,11 @@ export default function ChatView(props: ChatViewProps) {
7778 },7746 },
7779 [activeThreadRef, isServerThread, onDiffPanelOpen],7747 [activeThreadRef, isServerThread, onDiffPanelOpen],
7780 );7748 );
7781 // Both the Map and the revert handler are read from refs at call-time so7749 // The revert handler is read from a ref at call-time so the callback
7782 // the callback reference is fully stable and never busts context identity.7750 // reference is fully stable and never busts TimelineRowCtx identity.
7783 const revertTurnCountRef = useRef(revertTurnCountByUserMessageId);
7784 revertTurnCountRef.current = revertTurnCountByUserMessageId;
7785 const onRevertToTurnCountRef = useRef(onRevertToTurnCount);7751 const onRevertToTurnCountRef = useRef(onRevertToTurnCount);
7786 onRevertToTurnCountRef.current = onRevertToTurnCount;7752 onRevertToTurnCountRef.current = onRevertToTurnCount;
7787 const onRevertUserMessage = useCallback((messageId: MessageId) => {7753 const onRevertTimelineTurn = useCallback((targetTurnCount: number) => {
7788 const targetTurnCount = revertTurnCountRef.current.get(messageId);
7789 if (typeof targetTurnCount !== "number") {
7790 return;
7791 }
7792 void onRevertToTurnCountRef.current(targetTurnCount);7754 void onRevertToTurnCountRef.current(targetTurnCount);
7793 }, []);7755 }, []);
77947756
...@@ -8104,12 +8066,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -8104,12 +8066,12 @@ export default function ChatView(props: ChatViewProps) {
8104 timelineEntries={timelineEntries}8066 timelineEntries={timelineEntries}
8105 latestTurn={activeLatestTurn}8067 latestTurn={activeLatestTurn}
8106 runningTurnId={activeRunningTurnId}8068 runningTurnId={activeRunningTurnId}
8107 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}8069 turnDiffSummaries={activeThread.checkpoints}
8108 activeThreadEnvironmentId={activeThread.environmentId}8070 activeThreadEnvironmentId={activeThread.environmentId}
8109 routeThreadKey={routeThreadKey}8071 routeThreadKey={routeThreadKey}
8110 onOpenTurnDiff={onOpenTurnDiff}8072 onOpenTurnDiff={onOpenTurnDiff}
8111 revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}8073 supportsConversationRollback={supportsConversationRollback}
8112 onRevertUserMessage={onRevertUserMessage}8074 onRevertToTurnCount={onRevertTimelineTurn}
8113 onUseArtifactTemplate={useArtifactTemplate}8075 onUseArtifactTemplate={useArtifactTemplate}
8114 isRevertingCheckpoint={isRevertingCheckpoint}8076 isRevertingCheckpoint={isRevertingCheckpoint}
8115 onImageExpand={onExpandTimelineImage}8077 onImageExpand={onExpandTimelineImage}
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+330-113
...@@ -1,5 +1,22 @@...@@ -1,5 +1,22 @@
1import { describe, expect, it } from "vite-plus/test";1import { 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";
3import {20import {
4 computeStableMessagesTimelineRows,21 computeStableMessagesTimelineRows,
5 computeMessageDurationStart,22 computeMessageDurationStart,
...@@ -12,6 +29,7 @@ import {...@@ -12,6 +29,7 @@ import {
12 shouldFollowWorkGroupAppend,29 shouldFollowWorkGroupAppend,
13 shouldPreserveAssistantLineBreaks,30 shouldPreserveAssistantLineBreaks,
14 type MessagesTimelineRow,31 type MessagesTimelineRow,
32 type MessagesTimelineRowsProjection,
15 workEntryDisplayLabel,33 workEntryDisplayLabel,
16} from "./MessagesTimeline.logic";34} from "./MessagesTimeline.logic";
17import {35import {
...@@ -19,8 +37,8 @@ import {...@@ -19,8 +37,8 @@ import {
19 deriveTimelineEntries,37 deriveTimelineEntries,
20 deriveTimelineEntriesWithState,38 deriveTimelineEntriesWithState,
21 type WorkLogEntry,39 type WorkLogEntry,
40 type TimelineEntriesProjection,
22} from "../../session-logic";41} from "../../session-logic";
23import { buildRevertTurnCountByUserMessageId } from "../ChatView.logic";
24import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types";42import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types";
2543
26describe("streaming row projection", () => {44describe("streaming row projection", () => {
...@@ -97,8 +115,8 @@ describe("streaming row projection", () => {...@@ -97,8 +115,8 @@ describe("streaming row projection", () => {
97 runningTurnId: turnId,115 runningTurnId: turnId,
98 isWorking: true,116 isWorking: true,
99 activeTurnStartedAt: time(5),117 activeTurnStartedAt: time(5),
100 turnDiffSummaryByAssistantMessageId: new Map<MessageId, TurnDiffSummary>(),118 turnDiffSummaries: [],
101 revertTurnCountByUserMessageId: new Map<MessageId, number>(),119 supportsConversationRollback: false,
102 } satisfies Parameters<typeof deriveMessagesTimelineRows>[0];120 } satisfies Parameters<typeof deriveMessagesTimelineRows>[0];
103 return { messages, work, timeline, input, time, turnId, historyTurnId };121 return { messages, work, timeline, input, time, turnId, historyTurnId };
104 }122 }
...@@ -243,52 +261,54 @@ describe("streaming row projection", () => {...@@ -243,52 +261,54 @@ describe("streaming row projection", () => {
243 },261 },
244 );262 );
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", () => {
247 const initial = fixture("Partial");265 const initial = fixture("Partial");
248 const inferredCheckpointTurnCountByTurnId = { [initial.historyTurnId]: 1 };266 let checkpointLookupReads = 0;
249 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>([267 const summary: TurnDiffSummary = {
250 [268 turnId: initial.historyTurnId,
251 MessageId.make("history-assistant"),269 get checkpointTurnCount() {
252 {270 checkpointLookupReads += 1;
253 turnId: initial.historyTurnId,271 return 1;
254 checkpointTurnCount: 1,272 },
255 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"),273 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"),
256 status: "ready",274 status: "ready",
257 files: [],275 files: [],
258 assistantMessageId: MessageId.make("history-assistant"),276 get assistantMessageId() {
259 completedAt: initial.time(4),277 checkpointLookupReads += 1;
260 },278 return MessageId.make("history-assistant");
261 ],279 },
262 ]);280 get completedAt() {
263 let revertMap: Map<MessageId, number> | null = null;281 checkpointLookupReads += 1;
264 // Mirrors ChatView: the map is derived from each delta's entries.282 return initial.time(4);
265 const build = (timelineEntries: typeof initial.timeline.entries) => {283 },
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 };
281 };284 };
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);
283 expect(previous.rows.some((row) => row.kind === "message" && row.revertTurnCount === 0)).toBe(294 expect(previous.rows.some((row) => row.kind === "message" && row.revertTurnCount === 0)).toBe(
284 true,295 true,
285 );296 );
286 const last = initial.messages.at(-1)!;297 const last = initial.messages.at(-1)!;
287 const messages = [...initial.messages.slice(0, -1), { ...last, text: "Partial token" }];298 const messages = [...initial.messages.slice(0, -1), { ...last, text: "Partial token" }];
288 const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline);299 const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline);
289 const next = deriveMessagesTimelineRowsWithState(build(timeline.entries), previous);300 const nextInput = {
290301 ...input,
291 expect(next.rows).toEqual(deriveMessagesTimelineRows(build(timeline.entries)));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));
292 for (const [index, row] of previous.rows.entries()) {312 for (const [index, row] of previous.rows.entries()) {
293 if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) {313 if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) {
294 expect(next.rows[index]).toMatchObject({ message: { text: "Partial token" } });314 expect(next.rows[index]).toMatchObject({ message: { text: "Partial token" } });
...@@ -296,6 +316,205 @@ describe("streaming row projection", () => {...@@ -296,6 +316,205 @@ describe("streaming row projection", () => {
296 expect(next.rows[index]).toBe(row);316 expect(next.rows[index]).toBe(row);
297 }317 }
298 }318 }
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 }
299 });518 });
300519
301 it.each(["completion", "turn", "role", "ordering"] as const)(520 it.each(["completion", "turn", "role", "ordering"] as const)(
...@@ -407,7 +626,7 @@ describe("streaming row projection", () => {...@@ -407,7 +626,7 @@ describe("streaming row projection", () => {
407 ? { ...message, role: "user", turnId: null, createdAt: initial.time(0) }626 ? { ...message, role: "user", turnId: null, createdAt: initial.time(0) }
408 : message,627 : message,
409 );628 );
410 check({ revertTurnCountByUserMessageId: new Map([[MessageId.make("live-user"), 3]]) });629 check({ supportsConversationRollback: true });
411 });630 });
412});631});
413632
...@@ -574,8 +793,8 @@ describe("work entry labels", () => {...@@ -574,8 +793,8 @@ describe("work entry labels", () => {
574 ],793 ],
575 isWorking: false,794 isWorking: false,
576 activeTurnStartedAt: null,795 activeTurnStartedAt: null,
577 turnDiffSummaryByAssistantMessageId: new Map(),796 turnDiffSummaries: [],
578 revertTurnCountByUserMessageId: new Map(),797 supportsConversationRollback: false,
579 });798 });
580 const directRow = rows.find((row) => row.kind === "work");799 const directRow = rows.find((row) => row.kind === "work");
581 expect(directRow).toMatchObject({800 expect(directRow).toMatchObject({
...@@ -887,8 +1106,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -887,8 +1106,8 @@ describe("deriveMessagesTimelineRows", () => {
887 ],1106 ],
888 isWorking: false,1107 isWorking: false,
889 activeTurnStartedAt: null,1108 activeTurnStartedAt: null,
890 turnDiffSummaryByAssistantMessageId: new Map(),1109 turnDiffSummaries: [],
891 revertTurnCountByUserMessageId: new Map(),1110 supportsConversationRollback: false,
892 });1111 });
8931112
894 expect(rows).toEqual([1113 expect(rows).toEqual([
...@@ -950,8 +1169,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -950,8 +1169,8 @@ describe("deriveMessagesTimelineRows", () => {
950 expandedTurnIds: new Set(["turn-1" as never]),1169 expandedTurnIds: new Set(["turn-1" as never]),
951 isWorking: false,1170 isWorking: false,
952 activeTurnStartedAt: null,1171 activeTurnStartedAt: null,
953 turnDiffSummaryByAssistantMessageId: new Map(),1172 turnDiffSummaries: [],
954 revertTurnCountByUserMessageId: new Map(),1173 supportsConversationRollback: false,
955 });1174 });
9561175
957 const assistantRows = rows.filter(1176 const assistantRows = rows.filter(
...@@ -1004,8 +1223,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1004,8 +1223,8 @@ describe("deriveMessagesTimelineRows", () => {
1004 },1223 },
1005 isWorking: false,1224 isWorking: false,
1006 activeTurnStartedAt: null,1225 activeTurnStartedAt: null,
1007 turnDiffSummaryByAssistantMessageId: new Map(),1226 turnDiffSummaries: [],
1008 revertTurnCountByUserMessageId: new Map(),1227 supportsConversationRollback: false,
1009 });1228 });
10101229
1011 const assistantRows = rows.filter(1230 const assistantRows = rows.filter(
...@@ -1061,10 +1280,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1061,10 +1280,8 @@ describe("deriveMessagesTimelineRows", () => {
1061 ],1280 ],
1062 isWorking: false,1281 isWorking: false,
1063 activeTurnStartedAt: null,1282 activeTurnStartedAt: null,
1064 turnDiffSummaryByAssistantMessageId: new Map([1283 turnDiffSummaries: [assistantTurnDiffSummary],
1065 ["assistant-1" as never, assistantTurnDiffSummary],1284 supportsConversationRollback: true,
1066 ]),
1067 revertTurnCountByUserMessageId: new Map([["user-1" as never, 1]]),
1068 });1285 });
10691286
1070 const userRow = rows.find(1287 const userRow = rows.find(
...@@ -1142,8 +1359,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1142,8 +1359,8 @@ describe("deriveMessagesTimelineRows", () => {
1142 timelineEntries,1359 timelineEntries,
1143 isWorking: false,1360 isWorking: false,
1144 activeTurnStartedAt: null,1361 activeTurnStartedAt: null,
1145 turnDiffSummaryByAssistantMessageId: new Map(),1362 turnDiffSummaries: [],
1146 revertTurnCountByUserMessageId: new Map(),1363 supportsConversationRollback: false,
1147 });1364 });
11481365
1149 const foldRow = collapsedRows.find(1366 const foldRow = collapsedRows.find(
...@@ -1165,8 +1382,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1165,8 +1382,8 @@ describe("deriveMessagesTimelineRows", () => {
1165 expandedTurnIds: new Set(["turn-1" as never]),1382 expandedTurnIds: new Set(["turn-1" as never]),
1166 isWorking: false,1383 isWorking: false,
1167 activeTurnStartedAt: null,1384 activeTurnStartedAt: null,
1168 turnDiffSummaryByAssistantMessageId: new Map(),1385 turnDiffSummaries: [],
1169 revertTurnCountByUserMessageId: new Map(),1386 supportsConversationRollback: false,
1170 });1387 });
11711388
1172 expect(expandedRows.map((row) => row.id)).toEqual([1389 expect(expandedRows.map((row) => row.id)).toEqual([
...@@ -1235,8 +1452,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1235,8 +1452,8 @@ describe("deriveMessagesTimelineRows", () => {
1235 },1452 },
1236 isWorking: false,1453 isWorking: false,
1237 activeTurnStartedAt: null,1454 activeTurnStartedAt: null,
1238 turnDiffSummaryByAssistantMessageId: new Map(),1455 turnDiffSummaries: [],
1239 revertTurnCountByUserMessageId: new Map(),1456 supportsConversationRollback: false,
1240 };1457 };
1241 const rows = deriveMessagesTimelineRows({ ...input, timelineEntries });1458 const rows = deriveMessagesTimelineRows({ ...input, timelineEntries });
12421459
...@@ -1318,8 +1535,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1318,8 +1535,8 @@ describe("deriveMessagesTimelineRows", () => {
1318 timelineEntries,1535 timelineEntries,
1319 isWorking: false,1536 isWorking: false,
1320 activeTurnStartedAt: null,1537 activeTurnStartedAt: null,
1321 turnDiffSummaryByAssistantMessageId: new Map(),1538 turnDiffSummaries: [],
1322 revertTurnCountByUserMessageId: new Map(),1539 supportsConversationRollback: false,
1323 });1540 });
13241541
1325 expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]);1542 expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]);
...@@ -1421,8 +1638,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1421,8 +1638,8 @@ describe("deriveMessagesTimelineRows", () => {
1421 },1638 },
1422 isWorking: true,1639 isWorking: true,
1423 activeTurnStartedAt: "2026-01-01T00:00:14Z",1640 activeTurnStartedAt: "2026-01-01T00:00:14Z",
1424 turnDiffSummaryByAssistantMessageId: new Map(),1641 turnDiffSummaries: [],
1425 revertTurnCountByUserMessageId: new Map(),1642 supportsConversationRollback: false,
1426 });1643 });
14271644
1428 const foldRow = rows.find(1645 const foldRow = rows.find(
...@@ -1458,8 +1675,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1458,8 +1675,8 @@ describe("deriveMessagesTimelineRows", () => {
1458 },1675 },
1459 isWorking: false,1676 isWorking: false,
1460 activeTurnStartedAt: null,1677 activeTurnStartedAt: null,
1461 turnDiffSummaryByAssistantMessageId: new Map(),1678 turnDiffSummaries: [],
1462 revertTurnCountByUserMessageId: new Map(),1679 supportsConversationRollback: false,
1463 });1680 });
14641681
1465 expect(rows).toEqual([1682 expect(rows).toEqual([
...@@ -1526,8 +1743,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1526,8 +1743,8 @@ describe("deriveMessagesTimelineRows", () => {
1526 },1743 },
1527 isWorking: true,1744 isWorking: true,
1528 activeTurnStartedAt: "2026-01-01T00:01:00Z",1745 activeTurnStartedAt: "2026-01-01T00:01:00Z",
1529 turnDiffSummaryByAssistantMessageId: new Map(),1746 turnDiffSummaries: [],
1530 revertTurnCountByUserMessageId: new Map(),1747 supportsConversationRollback: false,
1531 });1748 });
15321749
1533 expect(rows.map((row) => row.id)).toEqual([1750 expect(rows.map((row) => row.id)).toEqual([
...@@ -1580,8 +1797,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1580,8 +1797,8 @@ describe("deriveMessagesTimelineRows", () => {
1580 },1797 },
1581 isWorking: true,1798 isWorking: true,
1582 activeTurnStartedAt: "2026-01-01T00:00:00Z",1799 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1583 turnDiffSummaryByAssistantMessageId: new Map(),1800 turnDiffSummaries: [],
1584 revertTurnCountByUserMessageId: new Map(),1801 supportsConversationRollback: false,
1585 });1802 });
15861803
1587 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);1804 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
...@@ -1674,8 +1891,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1674,8 +1891,8 @@ describe("deriveMessagesTimelineRows", () => {
1674 },1891 },
1675 isWorking: true,1892 isWorking: true,
1676 activeTurnStartedAt: "2026-01-01T00:01:00Z",1893 activeTurnStartedAt: "2026-01-01T00:01:00Z",
1677 turnDiffSummaryByAssistantMessageId: new Map(),1894 turnDiffSummaries: [],
1678 revertTurnCountByUserMessageId: new Map(),1895 supportsConversationRollback: false,
1679 });1896 });
16801897
1681 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);1898 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
...@@ -1754,8 +1971,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1754,8 +1971,8 @@ describe("deriveMessagesTimelineRows", () => {
1754 },1971 },
1755 isWorking: true,1972 isWorking: true,
1756 activeTurnStartedAt: "2026-01-01T00:00:00Z",1973 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1757 turnDiffSummaryByAssistantMessageId: new Map(),1974 turnDiffSummaries: [],
1758 revertTurnCountByUserMessageId: new Map(),1975 supportsConversationRollback: false,
1759 });1976 });
17601977
1761 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);1978 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
...@@ -1827,8 +2044,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1827,8 +2044,8 @@ describe("deriveMessagesTimelineRows", () => {
1827 },2044 },
1828 isWorking: true,2045 isWorking: true,
1829 activeTurnStartedAt: "2026-01-01T00:00:00Z",2046 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1830 turnDiffSummaryByAssistantMessageId: new Map(),2047 turnDiffSummaries: [],
1831 revertTurnCountByUserMessageId: new Map(),2048 supportsConversationRollback: false,
1832 });2049 });
18332050
1834 expect(rows.map((row) => row.kind)).toEqual(["working", "work", "message", "work-live"]);2051 expect(rows.map((row) => row.kind)).toEqual(["working", "work", "message", "work-live"]);
...@@ -1879,8 +2096,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1879,8 +2096,8 @@ describe("deriveMessagesTimelineRows", () => {
1879 expandedTurnIds: new Set([turnId]),2096 expandedTurnIds: new Set([turnId]),
1880 isWorking: false,2097 isWorking: false,
1881 activeTurnStartedAt: null,2098 activeTurnStartedAt: null,
1882 turnDiffSummaryByAssistantMessageId: new Map(),2099 turnDiffSummaries: [],
1883 revertTurnCountByUserMessageId: new Map(),2100 supportsConversationRollback: false,
1884 });2101 });
18852102
1886 expect(rows.find((row) => row.kind === "work")).toMatchObject({2103 expect(rows.find((row) => row.kind === "work")).toMatchObject({
...@@ -1947,8 +2164,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1947,8 +2164,8 @@ describe("deriveMessagesTimelineRows", () => {
1947 },2164 },
1948 isWorking: true,2165 isWorking: true,
1949 activeTurnStartedAt: "2026-01-01T00:00:00Z",2166 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1950 turnDiffSummaryByAssistantMessageId: new Map(),2167 turnDiffSummaries: [],
1951 revertTurnCountByUserMessageId: new Map(),2168 supportsConversationRollback: false,
1952 });2169 });
19532170
1954 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]);2171 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]);
...@@ -1994,8 +2211,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1994,8 +2211,8 @@ describe("deriveMessagesTimelineRows", () => {
1994 latestTurn: null,2211 latestTurn: null,
1995 isWorking: true,2212 isWorking: true,
1996 activeTurnStartedAt: "2026-01-01T00:01:00Z",2213 activeTurnStartedAt: "2026-01-01T00:01:00Z",
1997 turnDiffSummaryByAssistantMessageId: new Map(),2214 turnDiffSummaries: [],
1998 revertTurnCountByUserMessageId: new Map(),2215 supportsConversationRollback: false,
1999 });2216 });
20002217
2001 expect(rows.some((row) => row.kind === "work-live")).toBe(false);2218 expect(rows.some((row) => row.kind === "work-live")).toBe(false);
...@@ -2055,8 +2272,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2055,8 +2272,8 @@ describe("deriveMessagesTimelineRows", () => {
2055 },2272 },
2056 isWorking: true,2273 isWorking: true,
2057 activeTurnStartedAt: "2026-01-01T00:00:00Z",2274 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2058 turnDiffSummaryByAssistantMessageId: new Map(),2275 turnDiffSummaries: [],
2059 revertTurnCountByUserMessageId: new Map(),2276 supportsConversationRollback: false,
2060 });2277 });
20612278
2062 expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([2279 expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([
...@@ -2100,8 +2317,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2100,8 +2317,8 @@ describe("deriveMessagesTimelineRows", () => {
2100 },2317 },
2101 isWorking: true,2318 isWorking: true,
2102 activeTurnStartedAt: "2026-01-01T00:00:00Z",2319 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2103 turnDiffSummaryByAssistantMessageId: new Map(),2320 turnDiffSummaries: [],
2104 revertTurnCountByUserMessageId: new Map(),2321 supportsConversationRollback: false,
2105 });2322 });
21062323
2107 const workLiveRow = rows.find((row) => row.kind === "work-live");2324 const workLiveRow = rows.find((row) => row.kind === "work-live");
...@@ -2148,8 +2365,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2148,8 +2365,8 @@ describe("deriveMessagesTimelineRows", () => {
2148 },2365 },
2149 isWorking: true,2366 isWorking: true,
2150 activeTurnStartedAt: "2026-01-01T00:00:00Z",2367 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2151 turnDiffSummaryByAssistantMessageId: new Map(),2368 turnDiffSummaries: [],
2152 revertTurnCountByUserMessageId: new Map(),2369 supportsConversationRollback: false,
2153 });2370 });
21542371
2155 const initialRows = deriveRows(null);2372 const initialRows = deriveRows(null);
...@@ -2226,8 +2443,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2226,8 +2443,8 @@ describe("deriveMessagesTimelineRows", () => {
2226 runningTurnId: "turn-2" as never,2443 runningTurnId: "turn-2" as never,
2227 isWorking: true,2444 isWorking: true,
2228 activeTurnStartedAt: "2026-01-01T00:01:00Z",2445 activeTurnStartedAt: "2026-01-01T00:01:00Z",
2229 turnDiffSummaryByAssistantMessageId: new Map(),2446 turnDiffSummaries: [],
2230 revertTurnCountByUserMessageId: new Map(),2447 supportsConversationRollback: false,
2231 });2448 });
22322449
2233 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([2450 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
...@@ -2271,8 +2488,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2271,8 +2488,8 @@ describe("deriveMessagesTimelineRows", () => {
2271 expandedTurnIds: new Set(["turn-1" as never]),2488 expandedTurnIds: new Set(["turn-1" as never]),
2272 isWorking: false,2489 isWorking: false,
2273 activeTurnStartedAt: null,2490 activeTurnStartedAt: null,
2274 turnDiffSummaryByAssistantMessageId: new Map(),2491 turnDiffSummaries: [],
2275 revertTurnCountByUserMessageId: new Map(),2492 supportsConversationRollback: false,
2276 });2493 });
22772494
2278 const assistantRows = rows.filter(2495 const assistantRows = rows.filter(
...@@ -2309,8 +2526,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2309,8 +2526,8 @@ describe("deriveMessagesTimelineRows", () => {
2309 },2526 },
2310 isWorking: true,2527 isWorking: true,
2311 activeTurnStartedAt: "2026-01-01T00:00:00Z",2528 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2312 turnDiffSummaryByAssistantMessageId: new Map(),2529 turnDiffSummaries: [],
2313 revertTurnCountByUserMessageId: new Map(),2530 supportsConversationRollback: false,
2314 });2531 });
23152532
2316 const assistantRow = rows.find(2533 const assistantRow = rows.find(
...@@ -2373,8 +2590,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2373,8 +2590,8 @@ describe("deriveMessagesTimelineRows", () => {
2373 timelineEntries,2590 timelineEntries,
2374 isWorking: false,2591 isWorking: false,
2375 activeTurnStartedAt: null,2592 activeTurnStartedAt: null,
2376 turnDiffSummaryByAssistantMessageId: new Map(),2593 turnDiffSummaries: [],
2377 revertTurnCountByUserMessageId: new Map(),2594 supportsConversationRollback: false,
2378 };2595 };
2379 const collapsedRows = deriveMessagesTimelineRows(baseInput);2596 const collapsedRows = deriveMessagesTimelineRows(baseInput);
2380 const expandedRows = deriveMessagesTimelineRows({2597 const expandedRows = deriveMessagesTimelineRows({
...@@ -2467,8 +2684,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2467,8 +2684,8 @@ describe("deriveMessagesTimelineRows", () => {
2467 timelineEntries,2684 timelineEntries,
2468 isWorking: false,2685 isWorking: false,
2469 activeTurnStartedAt: null,2686 activeTurnStartedAt: null,
2470 turnDiffSummaryByAssistantMessageId: new Map(),2687 turnDiffSummaries: [],
2471 revertTurnCountByUserMessageId: new Map(),2688 supportsConversationRollback: false,
2472 });2689 });
24732690
2474 expect(row).toMatchObject({2691 expect(row).toMatchObject({
...@@ -2509,8 +2726,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2509,8 +2726,8 @@ describe("deriveMessagesTimelineRows", () => {
2509 expandedTurnIds: new Set([turnId]),2726 expandedTurnIds: new Set([turnId]),
2510 runningTurnId: isWorking ? turnId : null,2727 runningTurnId: isWorking ? turnId : null,
2511 activeTurnStartedAt: isWorking ? createdAt : null,2728 activeTurnStartedAt: isWorking ? createdAt : null,
2512 turnDiffSummaryByAssistantMessageId: new Map(),2729 turnDiffSummaries: [],
2513 revertTurnCountByUserMessageId: new Map(),2730 supportsConversationRollback: false,
2514 };2731 };
2515 const expandedRows = deriveMessagesTimelineRows({2732 const expandedRows = deriveMessagesTimelineRows({
2516 ...input,2733 ...input,
...@@ -2549,8 +2766,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2549,8 +2766,8 @@ describe("deriveMessagesTimelineRows", () => {
2549 timelineEntries,2766 timelineEntries,
2550 isWorking: false,2767 isWorking: false,
2551 activeTurnStartedAt: null,2768 activeTurnStartedAt: null,
2552 turnDiffSummaryByAssistantMessageId: new Map(),2769 turnDiffSummaries: [],
2553 revertTurnCountByUserMessageId: new Map(),2770 supportsConversationRollback: false,
2554 });2771 });
25552772
2556 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({2773 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
...@@ -2596,8 +2813,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2596,8 +2813,8 @@ describe("deriveMessagesTimelineRows", () => {
2596 timelineEntries,2813 timelineEntries,
2597 isWorking: false,2814 isWorking: false,
2598 activeTurnStartedAt: null,2815 activeTurnStartedAt: null,
2599 turnDiffSummaryByAssistantMessageId: new Map(),2816 turnDiffSummaries: [],
2600 revertTurnCountByUserMessageId: new Map(),2817 supportsConversationRollback: false,
2601 });2818 });
26022819
2603 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({2820 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
...@@ -2653,8 +2870,8 @@ describe("computeStableMessagesTimelineRows", () => {...@@ -2653,8 +2870,8 @@ describe("computeStableMessagesTimelineRows", () => {
2653 runningTurnId: turnId,2870 runningTurnId: turnId,
2654 isWorking: true,2871 isWorking: true,
2655 activeTurnStartedAt: startedAt,2872 activeTurnStartedAt: startedAt,
2656 turnDiffSummaryByAssistantMessageId: new Map(),2873 turnDiffSummaries: [],
2657 revertTurnCountByUserMessageId: new Map(),2874 supportsConversationRollback: false,
2658 };2875 };
2659 const assistantEntry = {2876 const assistantEntry = {
2660 id: "assistant-entry",2877 id: "assistant-entry",
...@@ -2731,8 +2948,8 @@ describe("computeStableMessagesTimelineRows", () => {...@@ -2731,8 +2948,8 @@ describe("computeStableMessagesTimelineRows", () => {
2731 ],2948 ],
2732 isWorking: false,2949 isWorking: false,
2733 activeTurnStartedAt: null,2950 activeTurnStartedAt: null,
2734 turnDiffSummaryByAssistantMessageId: new Map(),2951 turnDiffSummaries: [],
2735 revertTurnCountByUserMessageId: new Map(),2952 supportsConversationRollback: false,
2736 });2953 });
27372954
2738 const initial = computeStableMessagesTimelineRows(rows, {2955 const initial = computeStableMessagesTimelineRows(rows, {
...@@ -2780,8 +2997,8 @@ describe("computeStableMessagesTimelineRows", () => {...@@ -2780,8 +2997,8 @@ describe("computeStableMessagesTimelineRows", () => {
2780 ],2997 ],
2781 isWorking: false,2998 isWorking: false,
2782 activeTurnStartedAt: null,2999 activeTurnStartedAt: null,
2783 turnDiffSummaryByAssistantMessageId: new Map(),3000 turnDiffSummaries: [],
2784 revertTurnCountByUserMessageId: new Map(),3001 supportsConversationRollback: false,
2785 });3002 });
27863003
2787 const firstRows = createRows();3004 const firstRows = createRows();
...@@ -2836,8 +3053,8 @@ describe("computeStableMessagesTimelineRows", () => {...@@ -2836,8 +3053,8 @@ describe("computeStableMessagesTimelineRows", () => {
2836 ],3053 ],
2837 isWorking: false,3054 isWorking: false,
2838 activeTurnStartedAt: null,3055 activeTurnStartedAt: null,
2839 turnDiffSummaryByAssistantMessageId: new Map(),3056 turnDiffSummaries: [],
2840 revertTurnCountByUserMessageId: new Map(),3057 supportsConversationRollback: false,
2841 });3058 });
28423059
2843 const initial = computeStableMessagesTimelineRows(firstRows, {3060 const initial = computeStableMessagesTimelineRows(firstRows, {
apps/web/src/components/chat/MessagesTimeline.logic.ts+82-7
...@@ -18,6 +18,7 @@ export {...@@ -18,6 +18,7 @@ export {
18} from "@t3tools/client-runtime/work-log/presentation";18} from "@t3tools/client-runtime/work-log/presentation";
19import {19import {
20 formatDuration,20 formatDuration,
21 inferCheckpointTurnCountByTurnId,
21 isStreamingMessageTextUpdate,22 isStreamingMessageTextUpdate,
22 workEntryDisplayIndicatesToolFailure,23 workEntryDisplayIndicatesToolFailure,
23 workEntryIndicatesToolSuccess,24 workEntryIndicatesToolSuccess,
...@@ -776,6 +777,45 @@ function attachTrailingToolGroupsToAssistant(...@@ -776,6 +777,45 @@ function attachTrailingToolGroupsToAssistant(
776 return result;777 return result;
777}778}
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
779export function deriveMessagesTimelineRows(input: {819export function deriveMessagesTimelineRows(input: {
780 timelineEntries: ReadonlyArray<TimelineEntry>;820 timelineEntries: ReadonlyArray<TimelineEntry>;
781 latestTurn?: TimelineLatestTurn | null;821 latestTurn?: TimelineLatestTurn | null;
...@@ -784,9 +824,23 @@ export function deriveMessagesTimelineRows(input: {...@@ -784,9 +824,23 @@ export function deriveMessagesTimelineRows(input: {
784 expandedWorkGroupIds?: ReadonlySet<string>;824 expandedWorkGroupIds?: ReadonlySet<string>;
785 isWorking: boolean;825 isWorking: boolean;
786 activeTurnStartedAt: string | null;826 activeTurnStartedAt: string | null;
787 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;827 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
788 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;828 supportsConversationRollback: boolean;
789}): MessagesTimelineRow[] {829}): 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 });
790 const nextRows: MessagesTimelineRow[] = [];844 const nextRows: MessagesTimelineRow[] = [];
791 const durationStartByMessageId = computeMessageDurationStart(845 const durationStartByMessageId = computeMessageDurationStart(
792 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),846 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
...@@ -1134,11 +1188,11 @@ export function deriveMessagesTimelineRows(input: {...@@ -1134,11 +1188,11 @@ export function deriveMessagesTimelineRows(input: {
1134 assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress,1188 assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress,
1135 assistantTurnDiffSummary:1189 assistantTurnDiffSummary:
1136 timelineEntry.message.role === "assistant"1190 timelineEntry.message.role === "assistant"
1137 ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)1191 ? turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)
1138 : undefined,1192 : undefined,
1139 revertTurnCount:1193 revertTurnCount:
1140 timelineEntry.message.role === "user"1194 timelineEntry.message.role === "user"
1141 ? input.revertTurnCountByUserMessageId.get(timelineEntry.message.id)1195 ? revertTurnCountByUserMessageId.get(timelineEntry.message.id)
1142 : undefined,1196 : undefined,
1143 });1197 });
1144 }1198 }
...@@ -1168,9 +1222,30 @@ function replaceStreamingMessageRows(...@@ -1168,9 +1222,30 @@ function replaceStreamingMessageRows(
1168 input: MessagesTimelineRowsInput,1222 input: MessagesTimelineRowsInput,
1169 previous: MessagesTimelineRowsProjection,1223 previous: MessagesTimelineRowsProjection,
1170): MessagesTimelineRow[] | null {1224): MessagesTimelineRow[] | null {
1171 const { timelineEntries: previousEntries, ...previousContext } = previous.input;1225 const {
1172 const { timelineEntries, ...context } = input;1226 timelineEntries: previousEntries,
1173 if (timelineEntries.length !== previousEntries.length || !shallow(previousContext, context)) {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 ) {
1174 return null;1249 return null;
1175 }1250 }
1176 const replacements = new Map<ChatMessage, ChatMessage>();1251 const replacements = new Map<ChatMessage, ChatMessage>();
apps/web/src/components/chat/MessagesTimeline.test.tsx+14-19
...@@ -184,11 +184,11 @@ function buildProps() {...@@ -184,11 +184,11 @@ function buildProps() {
184 listRef: createRef<LegendListRef | null>(),184 listRef: createRef<LegendListRef | null>(),
185 latestTurn: null,185 latestTurn: null,
186 runningTurnId: null,186 runningTurnId: null,
187 turnDiffSummaryByAssistantMessageId: new Map(),187 turnDiffSummaries: [],
188 routeThreadKey: "environment-local:thread-1",188 routeThreadKey: "environment-local:thread-1",
189 onOpenTurnDiff: () => {},189 onOpenTurnDiff: () => {},
190 revertTurnCountByUserMessageId: new Map(),190 supportsConversationRollback: false,
191 onRevertUserMessage: () => {},191 onRevertToTurnCount: () => {},
192 isRevertingCheckpoint: false,192 isRevertingCheckpoint: false,
193 onImageExpand: () => {},193 onImageExpand: () => {},
194 activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID,194 activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
...@@ -448,22 +448,17 @@ describe("MessagesTimeline", () => {...@@ -448,22 +448,17 @@ describe("MessagesTimeline", () => {
448 },448 },
449 },449 },
450 ]}450 ]}
451 turnDiffSummaryByAssistantMessageId={451 turnDiffSummaries={[
452 new Map([452 {
453 [453 turnId,
454 assistantMessageId,454 checkpointTurnCount: 1,
455 {455 checkpointRef: CheckpointRef.make("checkpoint-with-files"),
456 turnId,456 status: "ready",
457 checkpointTurnCount: 1,457 files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],
458 checkpointRef: CheckpointRef.make("checkpoint-with-files"),458 assistantMessageId,
459 status: "ready",459 completedAt: MESSAGE_CREATED_AT,
460 files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],460 },
461 assistantMessageId,461 ]}
462 completedAt: MESSAGE_CREATED_AT,
463 },
464 ],
465 ])
466 }
467 />,462 />,
468 );463 );
469464
apps/web/src/components/chat/MessagesTimeline.tsx+19-17
...@@ -203,7 +203,7 @@ interface TimelineRowSharedState {...@@ -203,7 +203,7 @@ interface TimelineRowSharedState {
203 workspaceRoot: string | undefined;203 workspaceRoot: string | undefined;
204 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;204 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
205 activeThreadEnvironmentId: EnvironmentId;205 activeThreadEnvironmentId: EnvironmentId;
206 onRevertUserMessage: (messageId: MessageId) => void;206 onRevertToTurnCount: (targetTurnCount: number) => void;
207 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;207 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
208 onImageExpand: (preview: ExpandedImagePreview) => void;208 onImageExpand: (preview: ExpandedImagePreview) => void;
209 onFileOpen: (attachment: ChatFileAttachment) => void;209 onFileOpen: (attachment: ChatFileAttachment) => void;
...@@ -311,11 +311,11 @@ interface MessagesTimelineProps {...@@ -311,11 +311,11 @@ interface MessagesTimelineProps {
311 timelineEntries: ReturnType<typeof deriveTimelineEntries>;311 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
312 latestTurn: TimelineLatestTurn | null;312 latestTurn: TimelineLatestTurn | null;
313 runningTurnId: TurnId | null;313 runningTurnId: TurnId | null;
314 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;314 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
315 routeThreadKey: string;315 routeThreadKey: string;
316 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;316 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
317 revertTurnCountByUserMessageId: Map<MessageId, number>;317 supportsConversationRollback: boolean;
318 onRevertUserMessage: (messageId: MessageId) => void;318 onRevertToTurnCount: (targetTurnCount: number) => void;
319 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;319 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
320 isRevertingCheckpoint: boolean;320 isRevertingCheckpoint: boolean;
321 onImageExpand: (preview: ExpandedImagePreview) => void;321 onImageExpand: (preview: ExpandedImagePreview) => void;
...@@ -369,11 +369,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -369,11 +369,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
369 timelineEntries,369 timelineEntries,
370 latestTurn,370 latestTurn,
371 runningTurnId,371 runningTurnId,
372 turnDiffSummaryByAssistantMessageId,372 turnDiffSummaries,
373 routeThreadKey,373 routeThreadKey,
374 onOpenTurnDiff,374 onOpenTurnDiff,
375 revertTurnCountByUserMessageId,375 supportsConversationRollback,
376 onRevertUserMessage,376 onRevertToTurnCount,
377 onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,377 onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,
378 isRevertingCheckpoint,378 isRevertingCheckpoint,
379 onImageExpand,379 onImageExpand,
...@@ -543,8 +543,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -543,8 +543,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
543 expandedWorkGroupIds,543 expandedWorkGroupIds,
544 isWorking,544 isWorking,
545 activeTurnStartedAt,545 activeTurnStartedAt,
546 turnDiffSummaryByAssistantMessageId,546 turnDiffSummaries,
547 revertTurnCountByUserMessageId,547 supportsConversationRollback,
548 },548 },
549 previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot549 previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot
550 ? previous.projection550 ? previous.projection
...@@ -563,8 +563,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -563,8 +563,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
563 expandedWorkGroupIds,563 expandedWorkGroupIds,
564 isWorking,564 isWorking,
565 activeTurnStartedAt,565 activeTurnStartedAt,
566 turnDiffSummaryByAssistantMessageId,566 turnDiffSummaries,
567 revertTurnCountByUserMessageId,567 supportsConversationRollback,
568 ]);568 ]);
569 const rows = useStableRows(rawRows);569 const rows = useStableRows(rawRows);
570 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);570 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
...@@ -729,7 +729,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -729,7 +729,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
729 workspaceRoot,729 workspaceRoot,
730 skills,730 skills,
731 activeThreadEnvironmentId,731 activeThreadEnvironmentId,
732 onRevertUserMessage,732 onRevertToTurnCount,
733 onUseArtifactTemplate,733 onUseArtifactTemplate,
734 onImageExpand,734 onImageExpand,
735 onFileOpen,735 onFileOpen,
...@@ -753,7 +753,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -753,7 +753,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
753 workspaceRoot,753 workspaceRoot,
754 skills,754 skills,
755 activeThreadEnvironmentId,755 activeThreadEnvironmentId,
756 onRevertUserMessage,756 onRevertToTurnCount,
757 onUseArtifactTemplate,757 onUseArtifactTemplate,
758 onImageExpand,758 onImageExpand,
759 onFileOpen,759 onFileOpen,
...@@ -1336,7 +1336,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1336,7 +1336,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1336 ];1336 ];
1337 const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-"));1337 const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-"));
1338 const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-"));1338 const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-"));
1339 const canRevertAgentWork = typeof row.revertTurnCount === "number";1339 const revertTurnCount = row.revertTurnCount;
13401340
1341 return (1341 return (
1342 <div className="group flex flex-col items-end gap-1">1342 <div className="group flex flex-col items-end gap-1">
...@@ -1498,7 +1498,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1498,7 +1498,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1498 </TooltipPopup>1498 </TooltipPopup>
1499 </Tooltip>1499 </Tooltip>
1500 <div className="flex items-center gap-0.5">1500 <div className="flex items-center gap-0.5">
1501 {canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}1501 {typeof revertTurnCount === "number" && (
1502 <RevertUserMessageButton turnCount={revertTurnCount} />
1503 )}
1502 {displayedUserMessage.copyText && (1504 {displayedUserMessage.copyText && (
1503 <MessageCopyButton text={displayedUserMessage.copyText} variant="ghost" />1505 <MessageCopyButton text={displayedUserMessage.copyText} variant="ghost" />
1504 )}1506 )}
...@@ -1509,7 +1511,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1509,7 +1511,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1509 );1511 );
1510}1512}
15111513
1512function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {1514function RevertUserMessageButton({ turnCount }: { turnCount: number }) {
1513 const ctx = use(TimelineRowCtx);1515 const ctx = use(TimelineRowCtx);
1514 const activity = use(TimelineRowActivityCtx);1516 const activity = use(TimelineRowActivityCtx);
15151517
...@@ -1522,7 +1524,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {...@@ -1522,7 +1524,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) {
1522 size="xs"1524 size="xs"
1523 variant="ghost"1525 variant="ghost"
1524 disabled={activity.isRevertingCheckpoint || activity.isWorking}1526 disabled={activity.isRevertingCheckpoint || activity.isWorking}
1525 onClick={() => ctx.onRevertUserMessage(messageId)}1527 onClick={() => ctx.onRevertToTurnCount(turnCount)}
1526 aria-label="Revert to this message"1528 aria-label="Revert to this message"
1527 />1529 />
1528 }1530 }