{
if (event.pointerType === "touch") return;
if (document.activeElement === peekRef.current) {
diff --git a/apps/web/src/components/chat/ComposerTasksBadge.test.tsx b/apps/web/src/components/chat/ComposerTasksBadge.test.tsx
index 24d77091eb..83778c7c2a 100644
--- a/apps/web/src/components/chat/ComposerTasksBadge.test.tsx
+++ b/apps/web/src/components/chat/ComposerTasksBadge.test.tsx
@@ -41,7 +41,7 @@ describe("ComposerTasksBadge", () => {
expect(markup).toContain("Tasks");
expect(markup).toContain("Attach task progress");
expect(markup).not.toContain("·");
- expect(markup).toContain("1/3");
+ expect(markup).toContain("1/3 complete");
expect(markup).toContain("Current task: Attach task progress");
expect(markup).toContain("lucide-list-todo");
expect(markup).toContain('aria-label="Dismiss tasks for this turn"');
@@ -67,7 +67,7 @@ describe("ComposerTasksBadge", () => {
/>,
);
- expect(markup).toContain("1/3");
+ expect(markup).toContain("1/3 complete");
expect(markup).not.toContain("data-composer-shoulder-tab");
});
@@ -114,9 +114,11 @@ describe("ComposerTasksBadge", () => {
expect(markup).toContain("text-primary");
expect(markup).toContain("text-muted-foreground/40");
expect(markup).not.toContain('data-slot="dot-matrix"');
- expect(markup).toContain("Completed:");
- expect(markup).toContain("In progress:");
- expect(markup).toContain("Pending:");
+ // Each row names its state in text, so status never rests on glyph color alone.
+ expect(markup).toContain(">Completed");
+ expect(markup).toContain(">Running");
+ expect(markup).toContain(">Pending");
+ expect(markup).not.toContain('class="sr-only"');
});
it("does not render an empty task count", () => {
diff --git a/apps/web/src/components/chat/ComposerTasksBadge.tsx b/apps/web/src/components/chat/ComposerTasksBadge.tsx
index b6482a6b2e..494415c2bf 100644
--- a/apps/web/src/components/chat/ComposerTasksBadge.tsx
+++ b/apps/web/src/components/chat/ComposerTasksBadge.tsx
@@ -155,7 +155,7 @@ function TaskSummary({
className={progress.completedSteps >= progress.totalSteps ? "text-success" : undefined}
data-composer-task-progress="true"
>
- {progress.completedSteps}/{progress.totalSteps}
+ {progress.completedSteps}/{progress.totalSteps} complete
{delegatesLabel ? (
-
{TASK_PROGRESS_STATUS_LABEL[step.status]}:
+
+ {TASK_PROGRESS_STATUS_LABEL[step.status]}
+
{
).toBe(false);
});
});
+
+describe("shouldReserveContextWindowMeter", () => {
+ const loadingStartedThread = {
+ meterEnabled: true,
+ detailLoading: true,
+ threadStarted: true,
+ providerReportsContextWindow: true,
+ };
+
+ it("holds the meter's slot while a started thread's detail loads", () => {
+ expect(shouldReserveContextWindowMeter(loadingStartedThread)).toBe(true);
+ });
+
+ it("reserves nothing once the detail is in", () => {
+ expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, detailLoading: false })).toBe(
+ false,
+ );
+ });
+
+ it("reserves nothing for a thread that never ran a turn", () => {
+ expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, threadStarted: false })).toBe(
+ false,
+ );
+ });
+
+ it("reserves while the thread's provider is not in the catalog yet", () => {
+ expect(
+ shouldReserveContextWindowMeter({
+ ...loadingStartedThread,
+ providerReportsContextWindow: null,
+ }),
+ ).toBe(true);
+ });
+
+ it("reserves nothing for a provider that does not stream usage", () => {
+ expect(
+ shouldReserveContextWindowMeter({
+ ...loadingStartedThread,
+ providerReportsContextWindow: false,
+ }),
+ ).toBe(false);
+ });
+
+ it("reserves nothing while the meter is switched off", () => {
+ expect(shouldReserveContextWindowMeter({ ...loadingStartedThread, meterEnabled: false })).toBe(
+ false,
+ );
+ });
+});
diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts
index 6ff2b6e0a6..a04582a042 100644
--- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts
+++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts
@@ -108,3 +108,30 @@ export function formatContextWindowCompactionMessage(
? `Context for ${modelDisplayName} compacts automatically when needed.`
: "Context compacts automatically when needed.";
}
+
+/**
+ * Whether the footer should hold the meter's slot before a snapshot exists.
+ *
+ * The snapshot comes from thread activities, which load after the shell.
+ * Reserving the slot while the detail loads, for a started thread, keeps the
+ * attach button still until the meter mounts. Once the detail is in, a
+ * missing snapshot means there is no usage to show and nothing is reserved.
+ *
+ * The meter renders from stored activities whatever the provider's state, so
+ * only a provider known not to stream usage skips the reservation. An unknown
+ * provider (catalog still loading, or the thread's provider disabled) reserves.
+ */
+export function shouldReserveContextWindowMeter(input: {
+ readonly meterEnabled: boolean;
+ readonly detailLoading: boolean;
+ readonly threadStarted: boolean;
+ /** `null` while the thread's provider is not in the catalog. */
+ readonly providerReportsContextWindow: boolean | null;
+}): boolean {
+ return (
+ input.meterEnabled &&
+ input.detailLoading &&
+ input.threadStarted &&
+ input.providerReportsContextWindow !== false
+ );
+}
diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx
index 509d730b76..1c8af0fa92 100644
--- a/apps/web/src/components/chat/ContextWindowMeter.tsx
+++ b/apps/web/src/components/chat/ContextWindowMeter.tsx
@@ -279,3 +279,8 @@ export function ContextWindowMeter(props: {
);
}
+
+/** Holds the meter's footprint while a thread's activities are still loading. */
+export function ContextWindowMeterPlaceholder() {
+ return ;
+}
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
index b0bdb5fa17..05f4b96897 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
@@ -1,5 +1,24 @@
import { describe, expect, it } from "vite-plus/test";
-import { CheckpointRef, MessageId, TurnId } from "@t3tools/contracts";
+import {
+ CheckpointRef,
+ EnvironmentId,
+ EventId,
+ MessageId,
+ ProjectId,
+ ProviderInstanceId,
+ ThreadId,
+ TurnId,
+ type OrchestrationThread,
+} from "@t3tools/contracts";
+import {
+ applyThreadDetailEvent,
+ codexFeedbackMessage,
+ createEnvironmentThreadDetailAtoms,
+ EMPTY_ENVIRONMENT_THREAD_STATE,
+} from "@t3tools/client-runtime/state/threads";
+import { deriveRollbackTargets } from "@t3tools/client-runtime/rollback";
+import * as Option from "effect/Option";
+import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
import {
computeStableMessagesTimelineRows,
computeMessageDurationStart,
@@ -12,6 +31,7 @@ import {
shouldFollowWorkGroupAppend,
shouldPreserveAssistantLineBreaks,
type MessagesTimelineRow,
+ type MessagesTimelineRowsProjection,
workEntryDisplayLabel,
} from "./MessagesTimeline.logic";
import {
@@ -19,9 +39,9 @@ import {
deriveTimelineEntries,
deriveTimelineEntriesWithState,
type WorkLogEntry,
+ type TimelineEntriesProjection,
} from "../../session-logic";
-import { buildRollbackTurnCountByMessageId } from "../ChatView.logic";
-import { deriveRollbackTargets } from "@t3tools/client-runtime/rollback";
+import { collectLocalTimelineMessageIds } from "../ChatView.logic";
import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types";
describe("streaming row projection", () => {
@@ -98,8 +118,8 @@ describe("streaming row projection", () => {
runningTurnId: turnId,
isWorking: true,
activeTurnStartedAt: time(5),
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
} satisfies Parameters[0];
return { messages, work, timeline, input, time, turnId, historyTurnId };
}
@@ -240,83 +260,444 @@ describe("streaming row projection", () => {
},
);
- it("reuses streamed rows while preserving exact rollback proof and revocation", () => {
+ it("owns checkpoint lookups across streaming while preserving exact rollback proof", () => {
const initial = fixture("Partial");
+ let checkpointLookupReads = 0;
const checkpoint: TurnDiffSummary = {
turnId: initial.historyTurnId,
- checkpointTurnCount: 1,
+ get checkpointTurnCount() {
+ checkpointLookupReads += 1;
+ return 1;
+ },
checkpointRef: CheckpointRef.make("refs/pylon/checkpoints/history"),
status: "ready",
files: [],
- assistantMessageId: MessageId.make("history-assistant"),
+ get assistantMessageId() {
+ checkpointLookupReads += 1;
+ return MessageId.make("history-assistant");
+ },
completedAt: initial.time(4),
};
const baseline: TurnDiffSummary = {
- ...checkpoint,
+ turnId: initial.historyTurnId,
checkpointTurnCount: 0,
+ checkpointRef: CheckpointRef.make("refs/pylon/checkpoints/baseline"),
+ status: "ready",
+ files: [],
assistantMessageId: null,
rollbackAvailability: { state: "available", reason: "Exact anchor verified." },
+ completedAt: initial.time(0),
};
- const summaries = new Map([[checkpoint.assistantMessageId!, checkpoint]]);
- let counts: Map | null = null;
- const build = (
- messages: ChatMessage[],
- timelineEntries: typeof initial.timeline.entries,
- verified = true,
- ) => {
- counts = buildRollbackTurnCountByMessageId(
- deriveRollbackTargets({
- messages,
- checkpoints: [
- {
- ...baseline,
- rollbackAvailability: verified ? baseline.rollbackAvailability : undefined,
- },
- checkpoint,
- ],
- }),
- counts,
- );
- return {
- ...initial.input,
- timelineEntries,
- turnDiffSummaryByAssistantMessageId: summaries,
- revertTurnCountByUserMessageId: counts,
- };
+ const input = {
+ ...initial.input,
+ turnDiffSummaries: [baseline, checkpoint],
+ supportsConversationRollback: true,
+ expandedTurnIds: new Set([initial.historyTurnId]),
+ expandedWorkGroupIds: new Set(),
};
- const previous = deriveMessagesTimelineRowsWithState(
- build(initial.messages, initial.timeline.entries),
- );
- expect(
- previous.rows.find(
+ const firstUserRow = (projection: MessagesTimelineRowsProjection) =>
+ projection.rows.find(
(row) => row.kind === "message" && row.message.id === initial.messages[0]!.id,
- ),
- ).toMatchObject({ revertTurnCount: 0 });
+ );
+ const previous = deriveMessagesTimelineRowsWithState(input);
+ expect(checkpointLookupReads).toBeGreaterThan(0);
+ expect(firstUserRow(previous)).toMatchObject({ revertTurnCount: 0 });
+
const last = initial.messages.at(-1)!;
const messages = [...initial.messages.slice(0, -1), { ...last, text: "Partial token" }];
const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline);
- const nextInput = build(messages, timeline.entries);
+ // Equal source snapshots arrive as fresh containers on every streamed token.
+ const nextInput = {
+ ...input,
+ timelineEntries: timeline.entries,
+ turnDiffSummaries: [...input.turnDiffSummaries],
+ latestTurn: { ...input.latestTurn },
+ expandedTurnIds: new Set(input.expandedTurnIds),
+ expandedWorkGroupIds: new Set(input.expandedWorkGroupIds),
+ };
+ checkpointLookupReads = 0;
const next = deriveMessagesTimelineRowsWithState(nextInput, previous);
+ expect(checkpointLookupReads).toBe(0);
expect(next.rows).toEqual(deriveMessagesTimelineRows(nextInput));
for (const [index, row] of previous.rows.entries()) {
- if (row.kind === "message" && row.message === last) {
+ if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) {
expect(next.rows[index]).toMatchObject({ message: { text: "Partial token" } });
} else expect(next.rows[index]).toBe(row);
}
+
+ // The turn settles and its checkpoint lands, so the live request becomes
+ // revertible too; a later checkpoint count moves that target, and revoking
+ // its proof removes it again.
+ const userRow = (projection: MessagesTimelineRowsProjection, id: string) =>
+ projection.rows.find(
+ (row) => row.kind === "message" && row.message.id === MessageId.make(id),
+ );
+ const settledMessages = messages.map((message) =>
+ message.id === last.id
+ ? { ...message, text: "Done", streaming: false, updatedAt: initial.time(9) }
+ : message,
+ );
+ const settledTimeline = deriveTimelineEntriesWithState(
+ settledMessages,
+ [],
+ initial.work,
+ timeline,
+ );
+ const verifiedHistory: TurnDiffSummary = {
+ ...checkpoint,
+ rollbackAvailability: { state: "available", reason: "Exact anchor verified." },
+ };
+ const liveCheckpoint: TurnDiffSummary = {
+ turnId: initial.turnId,
+ checkpointTurnCount: 2,
+ checkpointRef: CheckpointRef.make("refs/pylon/checkpoints/live"),
+ status: "ready",
+ files: [],
+ assistantMessageId: last.id,
+ completedAt: initial.time(9),
+ };
+ const settledInput = {
+ ...nextInput,
+ timelineEntries: settledTimeline.entries,
+ latestTurn: {
+ ...nextInput.latestTurn,
+ state: "completed" as const,
+ completedAt: initial.time(9),
+ },
+ runningTurnId: null,
+ isWorking: false,
+ activeTurnStartedAt: null,
+ turnDiffSummaries: [baseline, verifiedHistory, liveCheckpoint],
+ };
+ const settled = deriveMessagesTimelineRowsWithState(settledInput, next);
+ expect(settled.rows).toEqual(deriveMessagesTimelineRows(settledInput));
+ expect(userRow(next, "live-user")).toMatchObject({ revertTurnCount: undefined });
+ expect(userRow(settled, "history-user")).toMatchObject({ revertTurnCount: 0 });
+ expect(userRow(settled, "live-user")).toMatchObject({ revertTurnCount: 1 });
+
+ const movedInput = {
+ ...settledInput,
+ turnDiffSummaries: [
+ baseline,
+ verifiedHistory,
+ {
+ ...baseline,
+ checkpointTurnCount: 2,
+ checkpointRef: CheckpointRef.make("refs/pylon/checkpoints/intermediate"),
+ completedAt: initial.time(8),
+ },
+ { ...liveCheckpoint, checkpointTurnCount: 3 },
+ ],
+ };
+ const moved = deriveMessagesTimelineRowsWithState(movedInput, settled);
+ expect(moved.rows).toEqual(deriveMessagesTimelineRows(movedInput));
+ expect(userRow(moved, "history-user")).toMatchObject({ revertTurnCount: 0 });
+ expect(userRow(moved, "live-user")).toMatchObject({ revertTurnCount: 2 });
+
+ const liveRevoked = deriveMessagesTimelineRowsWithState(
+ {
+ ...settledInput,
+ turnDiffSummaries: [
+ baseline,
+ { ...verifiedHistory, rollbackAvailability: undefined },
+ liveCheckpoint,
+ ],
+ },
+ settled,
+ );
+ expect(userRow(liveRevoked, "history-user")).toMatchObject({ revertTurnCount: 0 });
+ expect(userRow(liveRevoked, "live-user")).toMatchObject({ revertTurnCount: undefined });
+ expect(userRow(settled, "live-user")).toMatchObject({ revertTurnCount: 1 });
+
const revoked = deriveMessagesTimelineRowsWithState(
- build(messages, timeline.entries, false),
+ {
+ ...nextInput,
+ turnDiffSummaries: [{ ...baseline, rollbackAvailability: undefined }, checkpoint],
+ },
next,
);
- expect(
- revoked.rows.find(
- (row) => row.kind === "message" && row.message.id === initial.messages[0]!.id,
- ),
- ).toMatchObject({ revertTurnCount: undefined });
- expect(
- previous.rows.find(
- (row) => row.kind === "message" && row.message.id === initial.messages[0]!.id,
- ),
- ).toMatchObject({ revertTurnCount: 0 });
+ expect(firstUserRow(revoked)).toMatchObject({ revertTurnCount: undefined });
+ const unsupported = deriveMessagesTimelineRowsWithState(
+ { ...nextInput, supportsConversationRollback: false },
+ next,
+ );
+ expect(firstUserRow(unsupported)).toMatchObject({ revertTurnCount: undefined });
+ expect(firstUserRow(previous)).toMatchObject({ revertTurnCount: 0 });
+ });
+
+ it("never anchors a rollback on a local feedback transcript inside a turn", () => {
+ const initial = fixture("Partial");
+ const submission = {
+ id: MessageId.make("feedback-1"),
+ command: "/feedback",
+ createdAt: initial.time(1),
+ status: "sent" as const,
+ feedbackId: "feedback-thread",
+ };
+ // The transcript sorts between the request and its checkpointed response.
+ const messages: ChatMessage[] = [
+ initial.messages[0]!,
+ codexFeedbackMessage(submission),
+ codexFeedbackMessage(submission, "assistant"),
+ ...initial.messages.slice(1),
+ ];
+ const checkpoints: TurnDiffSummary[] = [
+ {
+ turnId: initial.historyTurnId,
+ checkpointTurnCount: 0,
+ checkpointRef: CheckpointRef.make("refs/pylon/checkpoints/baseline"),
+ status: "ready",
+ files: [],
+ assistantMessageId: null,
+ rollbackAvailability: { state: "available", reason: "Exact anchor verified." },
+ completedAt: initial.time(0),
+ },
+ {
+ turnId: initial.historyTurnId,
+ checkpointTurnCount: 1,
+ checkpointRef: CheckpointRef.make("refs/pylon/checkpoints/history"),
+ status: "ready",
+ files: [],
+ assistantMessageId: MessageId.make("history-assistant"),
+ completedAt: initial.time(4),
+ },
+ ];
+ const localMessageIds = collectLocalTimelineMessageIds([], [submission]);
+ expect([...localMessageIds]).toEqual(["feedback-1", "feedback-1:feedback"]);
+ expect(collectLocalTimelineMessageIds([], [])).toBe(collectLocalTimelineMessageIds([], []));
+ expect([
+ ...collectLocalTimelineMessageIds([{ id: MessageId.make("optimistic-1") }], []),
+ ]).toEqual(["optimistic-1"]);
+
+ const timeline = deriveTimelineEntriesWithState(messages, [], initial.work);
+ const input = {
+ ...initial.input,
+ timelineEntries: timeline.entries,
+ turnDiffSummaries: checkpoints,
+ supportsConversationRollback: true,
+ expandedTurnIds: new Set([initial.historyTurnId]),
+ localMessageIds,
+ };
+ const revertCounts = (rows: ReadonlyArray) =>
+ Object.fromEntries(
+ rows.flatMap((row) =>
+ row.kind === "message" && row.revertTurnCount !== undefined
+ ? [[row.message.id, row.revertTurnCount]]
+ : [],
+ ),
+ );
+ const serverTargets = deriveRollbackTargets({
+ messages: messages.filter((message) => !localMessageIds.has(message.id)),
+ checkpoints,
+ });
+ // Counted as server messages, the transcript would take the request's target.
+ const { localMessageIds: _localMessageIds, ...withoutLocalIds } = input;
+ expect(revertCounts(deriveMessagesTimelineRows(withoutLocalIds))).toEqual({ "feedback-1": 0 });
+ const projection = deriveMessagesTimelineRowsWithState(input);
+ expect(revertCounts(projection.rows)).toEqual({ "history-user": 0 });
+ expect(Object.keys(revertCounts(projection.rows))).toEqual([...serverTargets.keys()]);
+
+ // The local id set keeps its identity while the turn streams, so the rows
+ // stay on the fast path and the answer does not change.
+ const last = messages.at(-1)!;
+ const streamed = [...messages.slice(0, -1), { ...last, text: "Partial token" }];
+ const nextInput = {
+ ...input,
+ timelineEntries: deriveTimelineEntriesWithState(streamed, [], initial.work, timeline).entries,
+ };
+ const next = deriveMessagesTimelineRowsWithState(nextInput, projection);
+ expect(next.rows).toEqual(deriveMessagesTimelineRows(nextInput));
+ expect(revertCounts(next.rows)).toEqual({ "history-user": 0 });
+ for (const [index, row] of projection.rows.entries()) {
+ if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) {
+ continue;
+ }
+ expect(next.rows[index]).toBe(row);
+ }
+ });
+
+ it("reuses long-thread rows through detail events, selectors, and attachment previews", () => {
+ const initial = fixture("Partial");
+ let checkpointLookupReads = 0;
+ const history = Array.from({ length: 250 }, (_, index) => {
+ const turnId = TurnId.make(`older-turn-${index}`);
+ const user = {
+ ...initial.messages[0]!,
+ id: MessageId.make(`older-user-${index}`),
+ createdAt: new Date(Date.UTC(2026, 8, 3, 0, 0, index * 5)).toISOString(),
+ attachments: [
+ {
+ type: "image" as const,
+ id: "image",
+ name: "image.png",
+ mimeType: "image/png",
+ sizeBytes: 42,
+ },
+ ],
+ };
+ const assistant = {
+ ...initial.messages[1]!,
+ id: MessageId.make(`older-assistant-${index}`),
+ turnId,
+ createdAt: new Date(Date.UTC(2026, 8, 3, 0, 0, index * 5 + 3)).toISOString(),
+ };
+ const checkpoint: TurnDiffSummary = {
+ turnId,
+ get checkpointTurnCount() {
+ checkpointLookupReads += 1;
+ return index + 1;
+ },
+ checkpointRef: CheckpointRef.make(`refs/pylon/checkpoints/older-${index}`),
+ status: "ready",
+ files: [],
+ get assistantMessageId() {
+ checkpointLookupReads += 1;
+ return assistant.id;
+ },
+ rollbackAvailability: { state: "available", reason: "Exact anchor verified." },
+ completedAt: assistant.createdAt,
+ };
+ return { user, assistant, checkpoint };
+ });
+ const liveMessage = initial.messages.at(-1)!;
+ let thread: OrchestrationThread = {
+ id: ThreadId.make("streaming-thread"),
+ projectId: ProjectId.make("project"),
+ title: "Long thread",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ latestTurn: {
+ ...initial.input.latestTurn,
+ requestedAt: initial.time(5),
+ assistantMessageId: liveMessage.id,
+ },
+ createdAt: initial.time(0),
+ updatedAt: initial.time(7),
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ deletedAt: null,
+ messages: [
+ ...history.flatMap(({ user, assistant }) => [user, assistant]),
+ ...initial.messages,
+ ],
+ proposedPlans: [],
+ activities: [],
+ checkpoints: history.map(({ checkpoint }) => checkpoint),
+ session: null,
+ };
+ const state = Atom.make(
+ AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(thread) }),
+ );
+ const details = createEnvironmentThreadDetailAtoms(() => state);
+ const ref = { environmentId: EnvironmentId.make("local"), threadId: thread.id };
+ const registry = AtomRegistry.make();
+ const unmount = registry.mount(details.detailAtom(ref));
+ const preview = createMessageAttachmentPreviewProjector();
+ let imageUrl = "https://first.test/image";
+ let timeline: TimelineEntriesProjection | null = null;
+ let projection: MessagesTimelineRowsProjection | null = null;
+ const project = () => {
+ const selected = registry.get(details.detailAtom(ref));
+ if (selected === null) throw new Error("Missing thread detail");
+ const messages = selected.messages.map((message) => preview(message, () => imageUrl));
+ timeline = deriveTimelineEntriesWithState(
+ messages,
+ selected.proposedPlans,
+ initial.work,
+ timeline,
+ );
+ projection = deriveMessagesTimelineRowsWithState(
+ {
+ timelineEntries: timeline.entries,
+ latestTurn: selected.latestTurn,
+ runningTurnId:
+ selected.latestTurn?.state === "running" ? selected.latestTurn.turnId : null,
+ isWorking: selected.latestTurn?.state === "running",
+ activeTurnStartedAt: selected.latestTurn?.startedAt ?? null,
+ turnDiffSummaries: selected.checkpoints,
+ supportsConversationRollback: true,
+ },
+ projection,
+ );
+ return projection;
+ };
+ const send = (text: string, sequence: number, streaming = true) => {
+ const result = applyThreadDetailEvent(thread, {
+ eventId: EventId.make(`delta-${sequence}`),
+ sequence,
+ commandId: null,
+ causationEventId: null,
+ correlationId: null,
+ metadata: {},
+ occurredAt: initial.time(8 + sequence),
+ aggregateKind: "thread",
+ aggregateId: thread.id,
+ type: "thread.message-sent",
+ payload: {
+ threadId: thread.id,
+ messageId: liveMessage.id,
+ role: "assistant",
+ text,
+ turnId: initial.turnId,
+ streaming,
+ createdAt: liveMessage.createdAt,
+ updatedAt: initial.time(8 + sequence),
+ },
+ });
+ if (result.kind !== "updated") throw new Error("Message event did not update the thread");
+ thread = result.thread;
+ registry.set(
+ state,
+ AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(thread) }),
+ );
+ return project();
+ };
+
+ try {
+ const first = project();
+ const saved = structuredClone(first.rows);
+ expect(checkpointLookupReads).toBeGreaterThan(0);
+ expect(
+ first.rows.find(
+ (row) => row.kind === "message" && row.message.id === MessageId.make("older-user-3"),
+ ),
+ ).toMatchObject({ revertTurnCount: 3 });
+ checkpointLookupReads = 0;
+ for (let index = 0; index < 10; index += 1) {
+ const next = send(` ${index}`, index + 1);
+ for (const [rowIndex, row] of first.rows.entries()) {
+ if (
+ (row.kind === "message" || row.kind === "assistant-meta") &&
+ row.message.id === liveMessage.id
+ )
+ continue;
+ expect(next.rows[rowIndex]).toBe(row);
+ }
+ }
+ expect(checkpointLookupReads).toBe(0);
+ const streamed = project();
+ expect(streamed.rows).toEqual(deriveMessagesTimelineRows(streamed.input));
+
+ imageUrl = "https://renewed.test/image";
+ const renewed = project();
+ expect(renewed.rows[0]).not.toBe(first.rows[0]);
+ expect(renewed.rows[0]).toMatchObject({
+ message: { attachments: [{ previewUrl: imageUrl }] },
+ });
+ const completed = send("Complete", 11, false);
+ expect(completed.rows).toEqual(deriveMessagesTimelineRows(completed.input));
+ expect(
+ completed.rows.find((row) => row.kind === "message" && row.message.id === liveMessage.id),
+ ).toMatchObject({ message: { text: "Complete" }, assistantCopyStreaming: false });
+ expect(first.rows).toEqual(saved);
+ } finally {
+ unmount();
+ registry.dispose();
+ }
});
it.each(["completion", "turn", "role", "ordering"] as const)(
@@ -428,7 +809,7 @@ describe("streaming row projection", () => {
? { ...message, role: "user", turnId: null, createdAt: initial.time(0) }
: message,
);
- check({ revertTurnCountByUserMessageId: new Map([[MessageId.make("live-user"), 3]]) });
+ check({ supportsConversationRollback: true });
});
});
@@ -592,8 +973,8 @@ describe("work entry labels", () => {
],
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
const directRow = rows.find((row) => row.kind === "work");
expect(directRow).toMatchObject({
@@ -905,8 +1286,8 @@ describe("deriveMessagesTimelineRows", () => {
],
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows).toEqual([
@@ -968,8 +1349,8 @@ describe("deriveMessagesTimelineRows", () => {
expandedTurnIds: new Set(["turn-1" as never]),
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
const assistantRows = rows.filter(
@@ -1022,8 +1403,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
const assistantRows = rows.filter(
@@ -1079,10 +1460,18 @@ describe("deriveMessagesTimelineRows", () => {
],
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map([
- ["assistant-1" as never, assistantTurnDiffSummary],
- ]),
- revertTurnCountByUserMessageId: new Map([["user-1" as never, 1]]),
+ turnDiffSummaries: [
+ {
+ ...assistantTurnDiffSummary,
+ checkpointTurnCount: 1,
+ checkpointRef: "checkpoint-0" as never,
+ assistantMessageId: null,
+ files: [],
+ rollbackAvailability: { state: "available", reason: "Exact anchor verified." },
+ },
+ assistantTurnDiffSummary,
+ ],
+ supportsConversationRollback: true,
});
const userRow = rows.find(
@@ -1160,8 +1549,8 @@ describe("deriveMessagesTimelineRows", () => {
timelineEntries,
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
const foldRow = collapsedRows.find(
@@ -1183,8 +1572,8 @@ describe("deriveMessagesTimelineRows", () => {
expandedTurnIds: new Set(["turn-1" as never]),
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(expandedRows.map((row) => row.id)).toEqual([
@@ -1233,8 +1622,8 @@ describe("deriveMessagesTimelineRows", () => {
],
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
// Folding here would leave "Worked for 6s" above an empty response with the
@@ -1296,8 +1685,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
reportedTurnCosts: new Map([[turnId, 0.123456]]),
};
const rows = deriveMessagesTimelineRows({ ...input, timelineEntries });
@@ -1381,8 +1770,8 @@ describe("deriveMessagesTimelineRows", () => {
timelineEntries,
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]);
@@ -1426,8 +1815,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
// The notice keeps its own row outside the fold. Grouping renders it as a
@@ -1540,8 +1929,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:14Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
const foldRow = rows.find(
@@ -1577,8 +1966,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows).toEqual([
@@ -1645,8 +2034,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:01:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.map((row) => row.id)).toEqual([
@@ -1699,8 +2088,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
@@ -1793,8 +2182,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:01:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
@@ -1873,8 +2262,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
@@ -1946,8 +2335,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.map((row) => row.kind)).toEqual(["working", "work", "message", "work-live"]);
@@ -1998,8 +2387,8 @@ describe("deriveMessagesTimelineRows", () => {
expandedTurnIds: new Set([turnId]),
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.find((row) => row.kind === "work")).toMatchObject({
@@ -2066,8 +2455,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]);
@@ -2113,8 +2502,8 @@ describe("deriveMessagesTimelineRows", () => {
latestTurn: null,
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:01:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.some((row) => row.kind === "work-live")).toBe(false);
@@ -2174,8 +2563,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([
@@ -2219,8 +2608,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
if (toolLifecycleStatus === "failed") {
@@ -2266,8 +2655,8 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
const initialRows = deriveRows(null);
@@ -2344,8 +2733,8 @@ describe("deriveMessagesTimelineRows", () => {
runningTurnId: "turn-2" as never,
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:01:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([
@@ -2389,9 +2778,9 @@ describe("deriveMessagesTimelineRows", () => {
expandedTurnIds: new Set(["turn-1" as never]),
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
+ turnDiffSummaries: [],
reportedTurnCosts: new Map([["turn-1" as never, 0.123456]]),
- revertTurnCountByUserMessageId: new Map(),
+ supportsConversationRollback: false,
});
const assistantRows = rows.filter(
@@ -2432,9 +2821,9 @@ describe("deriveMessagesTimelineRows", () => {
},
isWorking: true,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
- turnDiffSummaryByAssistantMessageId: new Map(),
+ turnDiffSummaries: [],
reportedTurnCosts: new Map([["turn-1" as never, 1]]),
- revertTurnCountByUserMessageId: new Map(),
+ supportsConversationRollback: false,
});
const assistantRow = rows.find(
@@ -2500,8 +2889,8 @@ describe("deriveMessagesTimelineRows", () => {
timelineEntries,
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
};
const collapsedRows = deriveMessagesTimelineRows(baseInput);
const expandedRows = deriveMessagesTimelineRows({
@@ -2594,8 +2983,8 @@ describe("deriveMessagesTimelineRows", () => {
timelineEntries,
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(row).toMatchObject({
@@ -2636,8 +3025,8 @@ describe("deriveMessagesTimelineRows", () => {
expandedTurnIds: new Set([turnId]),
runningTurnId: isWorking ? turnId : null,
activeTurnStartedAt: isWorking ? createdAt : null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
};
const expandedRows = deriveMessagesTimelineRows({
...input,
@@ -2676,8 +3065,8 @@ describe("deriveMessagesTimelineRows", () => {
timelineEntries,
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
@@ -2723,8 +3112,8 @@ describe("deriveMessagesTimelineRows", () => {
timelineEntries,
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
@@ -2780,8 +3169,8 @@ describe("computeStableMessagesTimelineRows", () => {
runningTurnId: turnId,
isWorking: true,
activeTurnStartedAt: startedAt,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
};
const assistantEntry = {
id: "assistant-entry",
@@ -2858,8 +3247,8 @@ describe("computeStableMessagesTimelineRows", () => {
],
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
const initial = computeStableMessagesTimelineRows(rows, {
@@ -2907,8 +3296,8 @@ describe("computeStableMessagesTimelineRows", () => {
],
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
const firstRows = createRows();
@@ -2963,8 +3352,8 @@ describe("computeStableMessagesTimelineRows", () => {
],
isWorking: false,
activeTurnStartedAt: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
- revertTurnCountByUserMessageId: new Map(),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
});
const initial = computeStableMessagesTimelineRows(firstRows, {
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index 78fc1b6fe0..e48d9f3f60 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -15,6 +15,7 @@ import {
type WorkLogEntry,
} from "../../session-logic";
import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
+import { deriveRollbackTargets } from "@t3tools/client-runtime/rollback";
import { formatReportedTurnCost } from "@t3tools/client-runtime/state/turn-costs";
import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
import { formatWorkspaceRelativePath } from "../../filePathDisplay";
@@ -185,6 +186,34 @@ export function resolveTimelineMinimapIndexFromPointer(input: {
return Math.max(0, Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1))));
}
+export function resolveTimelineMinimapCurrentIndex(input: {
+ readonly scrollTop: number;
+ readonly scrollBottom: number;
+ readonly itemBounds: ReadonlyArray<{
+ readonly top: number | null;
+ readonly height: number | null;
+ }>;
+}): number | null {
+ let precedingIndex: number | null = null;
+
+ for (const [index, item] of input.itemBounds.entries()) {
+ if (item.top === null) {
+ continue;
+ }
+ const inView =
+ item.top < input.scrollBottom && item.top + Math.max(1, item.height ?? 1) > input.scrollTop;
+ if (inView) {
+ // The first visible marker is the turn at the reader's current position.
+ return index;
+ }
+ if (item.top <= input.scrollTop) {
+ precedingIndex = index;
+ }
+ }
+
+ return precedingIndex;
+}
+
export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean {
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
return false;
@@ -977,10 +1006,31 @@ export function deriveMessagesTimelineRows(input: {
expandedWorkGroupIds?: ReadonlySet;
isWorking: boolean;
activeTurnStartedAt: string | null;
- turnDiffSummaryByAssistantMessageId: ReadonlyMap;
+ turnDiffSummaries: ReadonlyArray;
reportedTurnCosts?: ReadonlyMap;
- revertTurnCountByUserMessageId: ReadonlyMap;
+ supportsConversationRollback: boolean;
+ /** Client-only messages (optimistic sends, feedback transcripts); never rollback anchors. */
+ localMessageIds?: ReadonlySet;
}): MessagesTimelineRow[] {
+ const turnDiffSummaryByAssistantMessageId = new Map();
+ for (const summary of input.turnDiffSummaries) {
+ if (summary.assistantMessageId) {
+ turnDiffSummaryByAssistantMessageId.set(summary.assistantMessageId, summary);
+ }
+ }
+ // Pylon's rollback proof: a user message is revertible only when the
+ // checkpoint before its response is ready and verified as available. Only
+ // server messages take part, so a local message cannot claim a target.
+ const rollbackTargets = input.supportsConversationRollback
+ ? deriveRollbackTargets({
+ messages: input.timelineEntries.flatMap((entry) =>
+ entry.kind === "message" && !input.localMessageIds?.has(entry.message.id)
+ ? [entry.message]
+ : [],
+ ),
+ checkpoints: input.turnDiffSummaries,
+ })
+ : null;
const nextRows: MessagesTimelineRow[] = [];
const durationStartByMessageId = computeMessageDurationStart(
input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
@@ -1336,11 +1386,11 @@ export function deriveMessagesTimelineRows(input: {
: undefined,
assistantTurnDiffSummary:
timelineEntry.message.role === "assistant"
- ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)
+ ? turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)
: undefined,
revertTurnCount:
timelineEntry.message.role === "user"
- ? input.revertTurnCountByUserMessageId.get(timelineEntry.message.id)
+ ? rollbackTargets?.get(timelineEntry.message.id)?.targetTurnCount
: undefined,
});
}
@@ -1370,9 +1420,30 @@ function replaceStreamingMessageRows(
input: MessagesTimelineRowsInput,
previous: MessagesTimelineRowsProjection,
): MessagesTimelineRow[] | null {
- const { timelineEntries: previousEntries, ...previousContext } = previous.input;
- const { timelineEntries, ...context } = input;
- if (timelineEntries.length !== previousEntries.length || !shallow(previousContext, context)) {
+ const {
+ timelineEntries: previousEntries,
+ turnDiffSummaries: previousSummaries,
+ latestTurn: previousLatestTurn,
+ expandedTurnIds: previousExpandedTurns,
+ expandedWorkGroupIds: previousExpandedGroups,
+ ...previousContext
+ } = previous.input;
+ const {
+ timelineEntries,
+ turnDiffSummaries,
+ latestTurn,
+ expandedTurnIds,
+ expandedWorkGroupIds,
+ ...context
+ } = input;
+ if (
+ timelineEntries.length !== previousEntries.length ||
+ !shallow(previousContext, context) ||
+ !shallow(previousSummaries, turnDiffSummaries) ||
+ !shallow(previousLatestTurn, latestTurn) ||
+ !shallow(previousExpandedTurns, expandedTurnIds) ||
+ !shallow(previousExpandedGroups, expandedWorkGroupIds)
+ ) {
return null;
}
const replacements = new Map();
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx
index a52dd3bcfa..8e1ddc1abe 100644
--- a/apps/web/src/components/chat/MessagesTimeline.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx
@@ -245,12 +245,11 @@ function buildProps() {
listRef: createRef(),
latestTurn: null,
runningTurnId: null,
- turnDiffSummaryByAssistantMessageId: new Map(),
+ turnDiffSummaries: [],
routeThreadKey: "environment-local:thread-1",
onOpenTurnDiff: () => {},
- revertTurnCountByUserMessageId: new Map(),
- onRevertUserMessage: () => {},
supportsConversationRollback: true,
+ onRevertUserMessage: () => {},
isRevertingCheckpoint: false,
onImageExpand: () => {},
activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID,
@@ -302,6 +301,25 @@ function buildAssistantTimelineEntry(text: string) {
}
describe("MessagesTimeline", () => {
+ it("renders previous and next controls with the minimap", () => {
+ const first = buildUserTimelineEntry("First turn");
+ const secondBase = buildUserTimelineEntry("Second turn");
+ const second = {
+ ...secondBase,
+ id: "entry-2",
+ message: {
+ ...secondBase.message,
+ id: MessageId.make("message-2"),
+ },
+ };
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain('aria-label="Previous turn"');
+ expect(markup).toContain('aria-label="Next turn"');
+ });
+
it.each([
{ toolLifecycleStatus: "inProgress", isAtEnd: true },
{ toolLifecycleStatus: "inProgress", isAtEnd: false },
@@ -530,22 +548,17 @@ describe("MessagesTimeline", () => {
},
},
]}
- turnDiffSummaryByAssistantMessageId={
- new Map([
- [
- assistantMessageId,
- {
- turnId,
- checkpointTurnCount: 1,
- checkpointRef: CheckpointRef.make("checkpoint-with-files"),
- status: "ready",
- files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],
- assistantMessageId,
- completedAt: MESSAGE_CREATED_AT,
- },
- ],
- ])
- }
+ turnDiffSummaries={[
+ {
+ turnId,
+ checkpointTurnCount: 1,
+ checkpointRef: CheckpointRef.make("checkpoint-with-files"),
+ status: "ready",
+ files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],
+ assistantMessageId,
+ completedAt: MESSAGE_CREATED_AT,
+ },
+ ]}
/>,
);
@@ -562,6 +575,7 @@ describe("MessagesTimeline", () => {
const {
resolveTimelineIsAtEnd,
resolveTimelineMinimapHasPersistentGutter,
+ resolveTimelineMinimapCurrentIndex,
resolveTimelineMinimapHeightStyle,
resolveTimelineMinimapHitStripWidth,
resolveTimelineMinimapIndexFromPointer,
@@ -621,6 +635,35 @@ describe("MessagesTimeline", () => {
pointerY: 999,
}),
).toBe(100);
+ expect(
+ resolveTimelineMinimapCurrentIndex({
+ scrollTop: 100,
+ scrollBottom: 500,
+ itemBounds: [
+ { top: 80, height: 20 },
+ { top: 120, height: 20 },
+ { top: 220, height: 20 },
+ ],
+ }),
+ ).toBe(1);
+ expect(
+ resolveTimelineMinimapCurrentIndex({
+ scrollTop: 150,
+ scrollBottom: 200,
+ itemBounds: [
+ { top: 80, height: 20 },
+ { top: 120, height: 20 },
+ { top: 220, height: 20 },
+ ],
+ }),
+ ).toBe(1);
+ expect(
+ resolveTimelineMinimapCurrentIndex({
+ scrollTop: 0,
+ scrollBottom: 50,
+ itemBounds: [{ top: 80, height: 20 }],
+ }),
+ ).toBeNull();
expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false);
expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false);
expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true);
@@ -716,13 +759,45 @@ describe("MessagesTimeline", () => {
expect(onAnchorReady).not.toHaveBeenCalled();
});
+ function buildRevertibleTimeline() {
+ const turnId = TurnId.make("turn-revertible");
+ const assistantMessageId = MessageId.make("message-assistant-revertible");
+ const userEntry = buildUserTimelineEntry("Hello");
+ const assistantEntry = {
+ id: "entry-assistant-revertible",
+ kind: "message" as const,
+ createdAt: MESSAGE_CREATED_AT,
+ message: {
+ id: assistantMessageId,
+ role: "assistant" as const,
+ text: "Done.",
+ turnId,
+ createdAt: MESSAGE_CREATED_AT,
+ updatedAt: MESSAGE_CREATED_AT,
+ streaming: false,
+ },
+ };
+ const checkpoint = (checkpointTurnCount: number, messageId: MessageId | null) => ({
+ turnId,
+ checkpointTurnCount,
+ checkpointRef: CheckpointRef.make(`checkpoint-${checkpointTurnCount}`),
+ status: "ready" as const,
+ files: [],
+ assistantMessageId: messageId,
+ rollbackAvailability: { state: "available" as const, reason: "Exact anchor verified." },
+ completedAt: MESSAGE_CREATED_AT,
+ });
+ return {
+ timelineEntries: [userEntry, assistantEntry],
+ turnDiffSummaries: [checkpoint(0, null), checkpoint(1, assistantMessageId)],
+ };
+ }
+
it("hides checkpoint revert when the provider capability is unavailable", () => {
- const messageId = MessageId.make("message-1");
const markup = renderToStaticMarkup(
,
);
@@ -731,13 +806,8 @@ describe("MessagesTimeline", () => {
});
it("renders checkpoint revert only when the provider capability is explicit", () => {
- const messageId = MessageId.make("message-1");
const markup = renderToStaticMarkup(
- ,
+ ,
);
expect(markup).toContain('aria-label="Revert to this message"');
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index ae347635f2..3bd90ac209 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -90,6 +90,7 @@ import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
+ ChevronUpIcon,
CircleAlertIcon,
DownloadIcon,
EyeIcon,
@@ -140,6 +141,7 @@ import {
resolveAssistantMessageCopyState,
resolveTimelineIsAtEnd,
resolveTimelineMinimapHasPersistentGutter,
+ resolveTimelineMinimapCurrentIndex,
resolveTimelineMinimapHeightStyle,
resolveTimelineMinimapHitStripWidth,
resolveTimelineMinimapIndexFromPointer,
@@ -210,7 +212,6 @@ interface TimelineRowSharedState {
skills: ReadonlyArray>;
activeThreadEnvironmentId: EnvironmentId;
onRevertUserMessage: (messageId: MessageId) => void;
- supportsConversationRollback: boolean;
revertDisabledReason?: string;
onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
onImageExpand: (preview: ExpandedImagePreview) => void;
@@ -324,13 +325,14 @@ interface MessagesTimelineProps {
timelineEntries: ReturnType;
latestTurn: TimelineLatestTurn | null;
runningTurnId: TurnId | null;
- turnDiffSummaryByAssistantMessageId: Map;
+ turnDiffSummaries: ReadonlyArray;
reportedTurnCosts?: ReadonlyMap;
routeThreadKey: string;
onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
- revertTurnCountByUserMessageId: Map;
- onRevertUserMessage: (messageId: MessageId) => void;
supportsConversationRollback: boolean;
+ /** Client-only message ids; see `collectLocalTimelineMessageIds`. */
+ localMessageIds?: ReadonlySet;
+ onRevertUserMessage: (messageId: MessageId) => void;
revertDisabledReason?: string;
onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
isRevertingCheckpoint: boolean;
@@ -387,13 +389,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({
timelineEntries,
latestTurn,
runningTurnId,
- turnDiffSummaryByAssistantMessageId,
+ turnDiffSummaries,
reportedTurnCosts,
routeThreadKey,
onOpenTurnDiff,
- revertTurnCountByUserMessageId,
- onRevertUserMessage,
supportsConversationRollback,
+ localMessageIds,
+ onRevertUserMessage,
revertDisabledReason,
onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,
isRevertingCheckpoint,
@@ -564,9 +566,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
expandedWorkGroupIds,
isWorking,
activeTurnStartedAt,
- turnDiffSummaryByAssistantMessageId,
+ turnDiffSummaries,
...(reportedTurnCosts === undefined ? {} : { reportedTurnCosts }),
- revertTurnCountByUserMessageId,
+ supportsConversationRollback,
+ ...(localMessageIds === undefined ? {} : { localMessageIds }),
},
previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot
? previous.projection
@@ -585,9 +588,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
expandedWorkGroupIds,
isWorking,
activeTurnStartedAt,
- turnDiffSummaryByAssistantMessageId,
+ turnDiffSummaries,
reportedTurnCosts,
- revertTurnCountByUserMessageId,
+ supportsConversationRollback,
+ localMessageIds,
]);
const rows = useStableRows(rawRows);
const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
@@ -612,6 +616,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
});
const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
+ const [minimapCurrentIndex, setMinimapCurrentIndex] = useState(null);
const handleAnchorReady = useCallback(
(info: { anchorIndex: number | undefined }) => {
if (anchorMessageId !== null && info.anchorIndex !== undefined) {
@@ -684,21 +689,33 @@ export const MessagesTimeline = memo(function MessagesTimeline({
const scrollTop = state.scroll ?? 0;
const scrollBottom = scrollTop + (state.scrollLength ?? 0);
- for (const item of minimapItems) {
- const strip = minimapStripMap.get(item.id);
- if (!strip) {
- continue;
- }
+ const itemBounds = minimapItems.map((item) => ({
+ top: resolveTimelineRowTop(state, item.rowIndex),
+ height: resolveTimelineRowHeight(state, item.rowIndex),
+ }));
- const rowTop = resolveTimelineRowTop(state, item.rowIndex);
- const rowHeight = resolveTimelineRowHeight(state, item.rowIndex);
+ for (const [index, item] of minimapItems.entries()) {
+ const strip = minimapStripMap.get(item.id);
+ const bounds = itemBounds[index];
+ const rowTop = bounds?.top ?? null;
+ const rowHeight = bounds?.height ?? null;
const inView =
rowTop !== null &&
rowTop < scrollBottom &&
rowTop + Math.max(1, rowHeight ?? 1) > scrollTop;
- strip.dataset.inView = inView ? "true" : "false";
+ if (strip) {
+ strip.dataset.inView = inView ? "true" : "false";
+ }
}
+ const nextCurrentIndex = resolveTimelineMinimapCurrentIndex({
+ scrollTop,
+ scrollBottom,
+ itemBounds,
+ });
+ setMinimapCurrentIndex((current) =>
+ current === nextCurrentIndex ? current : nextCurrentIndex,
+ );
}, [
citationPositioning,
listRef,
@@ -752,7 +769,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
skills,
activeThreadEnvironmentId,
onRevertUserMessage,
- supportsConversationRollback,
...(revertDisabledReason ? { revertDisabledReason } : {}),
onUseArtifactTemplate,
onImageExpand,
@@ -777,7 +793,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
skills,
activeThreadEnvironmentId,
onRevertUserMessage,
- supportsConversationRollback,
revertDisabledReason,
onUseArtifactTemplate,
onImageExpand,
@@ -902,6 +917,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
items={minimapItems}
hasPersistentGutter={minimapHasPersistentGutter}
hitStripWidth={minimapHitStripWidth}
+ currentIndex={minimapCurrentIndex}
stripMap={minimapStripMap}
onSelect={(item) => {
onManualNavigation();
@@ -1003,12 +1019,14 @@ function timelineMinimapEventTargetsPreview(target: EventTarget): boolean {
function TimelineMinimap({
hasPersistentGutter,
hitStripWidth,
+ currentIndex,
items,
stripMap,
onSelect,
}: {
hasPersistentGutter: boolean;
hitStripWidth: number;
+ currentIndex: number | null;
items: ReadonlyArray;
stripMap: Map;
onSelect: (item: TimelineMinimapItem) => void;
@@ -1030,6 +1048,11 @@ function TimelineMinimap({
: resolvedActiveIndex === items.length - 1
? "-100%"
: "-50%";
+ const resolvedCurrentIndex =
+ currentIndex !== null && currentIndex >= 0 && currentIndex < items.length ? currentIndex : null;
+ const previousItem =
+ resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex - 1] ?? null);
+ const nextItem = resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex + 1] ?? null);
const resolveActiveIndexFromPointer = useCallback(
(event: MouseEvent) => {
@@ -1078,128 +1101,187 @@ function TimelineMinimap({
data-persistent-gutter={hasPersistentGutter ? "true" : "false"}
>
-
+
{
+ if (nextItem) onSelect(nextItem);
+ }}
+ />
+