1import type { ThreadStructureRuntime } from "@t3tools/client-runtime/state/shell";
2import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models";
3import {
4 type AssetResource,
5 type OrchestrationV2Checkpoint,
6 type OrchestrationV2ExecutionNode,
7 type OrchestrationV2PlanArtifact,
8 type OrchestrationV2ProjectedTurnItem,
9 type OrchestrationV2RunAttempt,
10 type OrchestrationV2TurnItem,
11 type RunId,
12 type ToolActivitySurface,
13 type ToolActivityIcon,
14 type ToolActivitySource,
15 type ThreadId,
16 type TurnItemId,
17} from "@t3tools/contracts";
18import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
19import {
20 classifyToolActivity,
21 collectToolFilePaths,
22 formatReadToolLabel,
23 formatSearchToolLabel,
24} from "@t3tools/shared/toolActivity";
25import type { HtmlRenderReference } from "@t3tools/shared/htmlRender";
26import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail";
27import type { McpAppReference } from "@t3tools/shared/mcpApp";
28import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
29import {
30 contextCompactionLabel,
31 workEntryIndicatesToolFailure,
32} from "@t3tools/client-runtime/work-log/presentation";
33import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
34import type {
35 ThreadPendingApproval,
36 ThreadPendingUserInput,
37} from "@t3tools/client-runtime/state/thread-requests";
38import type { ThreadRunSummary, ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell";
39import { threadRuntimeHasInterruptibleRun } from "@t3tools/client-runtime/state/thread-execution";
40import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
41
42import {
43 isImageAttachment,
44 type ChatAttachment,
45 type ChatMessage,
46 type ProposedPlan,
47 type SessionPhase,
48 type TurnDiffSummary,
49} from "./types";
50import * as DateTime from "effect/DateTime";
51import * as Equal from "effect/Equal";
52import { shallow } from "zustand/vanilla/shallow";
53
54export { formatDuration } from "@t3tools/shared/orchestrationTiming";
55export {
56 workEntryDisplayIndicatesToolFailure,
57 workEntryIndicatesToolFailure,
58} from "@t3tools/client-runtime/work-log/presentation";
59
60export type WorkLogToolLifecycleStatus =
61 | "idle"
62 | "inProgress"
63 | "completed"
64 | "failed"
65 | "declined"
66 | "stopped";
67
68export interface WorkLogEntry {
69 readonly questionAnswer?: import("@t3tools/contracts").UserInputAttachmentAnswerPayload;
70 readonly id: string;
71 readonly createdAt: string;
72 readonly runId?: RunId | null;
73 readonly label: string;
74 readonly detail?: string;
75 readonly command?: string;
76 readonly rawCommand?: string;
77 readonly changedFiles?: ReadonlyArray<string>;
78 readonly tone: "thinking" | "tool" | "info" | "error";
79 readonly toolTitle?: string;
80 readonly toolCallId?: string;
81 readonly viewedImagePath?: string;
82 readonly toolSurface?: ToolActivitySurface;
83 readonly toolIcon?: ToolActivityIcon;
84 readonly toolSource?: ToolActivitySource;
85 readonly sourceActivityKind?: string;
86 readonly taskId?: string;
87 readonly agentRole?: string;
88 readonly toolData?: unknown;
89 readonly requestKind?: string;
90 readonly itemType?: OrchestrationV2TurnItem["type"];
91 readonly toolLifecycleStatus?: WorkLogToolLifecycleStatus;
92 readonly structuredPayload?: OrchestrationV2TurnItem;
93 readonly sourceItemType?: OrchestrationV2TurnItem["type"];
94 readonly projectedItem?: OrchestrationV2ProjectedTurnItem;
95}
96
97export type PendingApproval = ThreadPendingApproval;
98export type PendingUserInput = ThreadPendingUserInput;
99
100export type TimelineAttempt = Pick<
101 OrchestrationV2RunAttempt,
102 "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "completedAt"
103>;
104
105export type TimelineEntry = (
106 | {
107 readonly id: string;
108 readonly kind: "message";
109 readonly createdAt: string;
110 readonly message: ChatMessage;
111 readonly projectedItem?: OrchestrationV2ProjectedTurnItem;
112 }
113 | {
114 readonly id: string;
115 readonly kind: "proposed-plan";
116 readonly createdAt: string;
117 readonly proposedPlan: ProposedPlan;
118 }
119 | {
120 readonly id: string;
121 readonly kind: "html-render";
122 readonly createdAt: string;
123 readonly runId: RunId | null;
124 readonly htmlRender: HtmlRenderReference;
125 }
126 | {
127 readonly id: string;
128 readonly kind: "mcp-app";
129 readonly createdAt: string;
130 readonly runId: RunId | null;
131 readonly sourceThreadId: ThreadId;
132 readonly itemId: TurnItemId;
133 readonly revision: string;
134 readonly mcpApp: McpAppReference;
135 }
136 | {
137 readonly id: string;
138 readonly kind: "work";
139 readonly createdAt: string;
140 readonly entry: WorkLogEntry;
141 }
142 | {
143 readonly id: string;
144 readonly kind: "event";
145 readonly createdAt: string;
146 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
147 }
148) & {
149 readonly attempt?: TimelineAttempt;
150};
151
152export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
153 return (
154 entry.tone === "tool" ||
155 entry.tone === "thinking" ||
156 entry.tone === "error" ||
157 entry.command !== undefined ||
158 entry.requestKind !== undefined
159 );
160}
161
162export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean {
163 return entry.itemType === "error";
164}
165
166export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
167 if (
168 !workLogEntryIsToolLike(entry) ||
169 workEntryIndicatesToolFailure(entry) ||
170 (entry.tone === "thinking" && entry.itemType !== "reasoning")
171 ) {
172 return false;
173 }
174 const status = entry.toolLifecycleStatus;
175 return (
176 status !== "failed" &&
177 status !== "declined" &&
178 status !== "inProgress" &&
179 status !== "stopped" &&
180 status !== "idle"
181 );
182}
183
184export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {
185 return (
186 workLogEntryIsToolLike(entry) &&
187 !workEntryIndicatesToolFailure(entry) &&
188 !workEntryIndicatesToolSuccess(entry)
189 );
190}
191
192export function isLatestRunSettled(
193 latestRun: Pick<ThreadRunSummary, "runId" | "startedAt" | "completedAt" | "status"> | null,
194 runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId"> | null,
195): boolean {
196 if (latestRun === null) return false;
197 if (
198 latestRun.status === "preparing" ||
199 latestRun.status === "queued" ||
200 latestRun.status === "starting" ||
201 latestRun.status === "running" ||
202 latestRun.status === "waiting"
203 )
204 return false;
205 return runtime?.activeRunId !== latestRun.runId;
206}
207
208export function deriveActiveWorkStartedAt(
209 latestRun: Pick<
210 ThreadRunSummary,
211 "runId" | "startedAt" | "requestedAt" | "completedAt" | "status"
212 > | null,
213 runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId" | "activityStartedAt"> | null,
214 sendStartedAt: string | null,
215): string | null {
216 const startedAt = resolveThreadWorkingStartedAt({ latestRun, runtime });
217 return startedAt ?? (runtime?.activeRunId == null ? sendStartedAt : null);
218}
219
220export function derivePendingApprovals(
221 approvals: ReadonlyArray<ThreadPendingApproval>,
222): ThreadPendingApproval[] {
223 return [...approvals].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
224}
225
226export function derivePendingUserInputs(
227 inputs: ReadonlyArray<ThreadPendingUserInput>,
228): ThreadPendingUserInput[] {
229 return [...inputs].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
230}
231
232const STANDALONE_V2_ITEM_TYPES = new Set<OrchestrationV2ProjectedTurnItem["item"]["type"]>([
233 "fork",
234 "handoff",
235 "run_interrupt_request",
236 "run_interrupt_result",
237 "secret_request",
238 "link_request",
239 "subagent",
240]);
241
242const PERSISTENT_RESOURCE_V2_ITEM_TYPES = new Set<OrchestrationV2TurnItem["type"]>([
243 "fork",
244 "thread_created",
245 // Still answerable after a steer supersedes the attempt that asked.
246 "secret_request",
247 "link_request",
248]);
249
250export function timelineEntryIsPersistentResourceCard(entry: TimelineEntry): boolean {
251 return (
252 entry.kind === "event" && PERSISTENT_RESOURCE_V2_ITEM_TYPES.has(entry.projectedItem.item.type)
253 );
254}
255
256function projectedItemCreatedAt(row: OrchestrationV2ProjectedTurnItem): string {
257 return DateTime.formatIso(row.item.startedAt ?? row.item.updatedAt);
258}
259
260function projectedWorkEntryStatus(
261 item: OrchestrationV2TurnItem,
262): NonNullable<WorkLogEntry["toolLifecycleStatus"]> {
263 switch (item.status) {
264 case "pending":
265 case "running":
266 case "waiting":
267 return "inProgress";
268 case "completed":
269 return "completed";
270 case "idle":
271 return "idle";
272 case "failed":
273 return "failed";
274 case "cancelled":
275 case "interrupted":
276 return "stopped";
277 }
278}
279
280function projectedWorkEntryTone(item: OrchestrationV2TurnItem): WorkLogEntry["tone"] {
281 if (item.type === "error") return "info";
282 if (item.type === "reasoning") return "thinking";
283 switch (item.type) {
284 case "command_execution":
285 case "file_change":
286 case "file_search":
287 case "web_search":
288 case "dynamic_tool":
289 case "subagent":
290 case "thread_created":
291 case "user_input_request":
292 case "approval_request":
293 return "tool";
294 default:
295 return "info";
296 }
297}
298
299export function providerErrorPresentation(
300 item: Extract<OrchestrationV2TurnItem, { readonly type: "error" }>,
301): { readonly label: string; readonly detail: string } {
302 if (item.retry === undefined) {
303 return {
304 label:
305 item.failure.class === "usage_limit"
306 ? "Usage limit reached"
307 : item.title?.trim() || "Provider error",
308 detail: item.failure.message,
309 };
310 }
311 const progress =
312 item.retry.maxAttempts === null
313 ? `${item.retry.attempt}`
314 : `${item.retry.attempt}/${item.retry.maxAttempts}`;
315 const label =
316 item.status === "running"
317 ? `Retrying provider (${progress})`
318 : item.status === "completed"
319 ? `Provider recovered (${progress} retries)`
320 : item.status === "failed"
321 ? `${item.failure.class === "usage_limit" ? "Usage limit reached" : "Provider error"} after ${progress} retries`
322 : `Provider retry stopped (${progress})`;
323 const retryDelay =
324 item.status === "running" && item.retry.retryDelayMs !== null && item.retry.retryDelayMs > 0
325 ? item.retry.retryDelayMs < 1_000
326 ? ` Retrying in ${item.retry.retryDelayMs}ms.`
327 : ` Retrying in ${(item.retry.retryDelayMs / 1_000).toFixed(1).replace(/\.0$/u, "")}s.`
328 : "";
329 return {
330 label,
331 detail: `${item.failure.message}${retryDelay}`,
332 };
333}
334
335function projectedWorkEntry(row: OrchestrationV2ProjectedTurnItem): WorkLogEntry {
336 const { item } = row;
337 const title = item.title?.trim() || null;
338 const common = {
339 id: item.id,
340 createdAt: projectedItemCreatedAt(row),
341 runId: item.runId,
342 tone: projectedWorkEntryTone(item),
343 itemType: item.type,
344 toolLifecycleStatus: projectedWorkEntryStatus(item),
345 structuredPayload: item,
346 projectedItem: row,
347 ...extractToolActivityPresentation(item),
348 } as const;
349
350 switch (item.type) {
351 case "thread_created":
352 return {
353 ...common,
354 label: "Created thread",
355 };
356 case "compaction":
357 return {
358 ...common,
359 label: contextCompactionLabel(item),
360 sourceActivityKind: "context-compaction",
361 ...(item.summary ? { detail: item.summary } : {}),
362 };
363 case "reasoning":
364 return {
365 ...common,
366 label: title ?? "Thinking",
367 ...(item.text ? { detail: item.text } : {}),
368 };
369 case "command_execution":
370 return {
371 ...common,
372 label: title ?? "Ran command",
373 command: item.input,
374 rawCommand: item.input,
375 toolTitle: title ?? "Command",
376 toolData: item,
377 };
378 case "file_change": {
379 return {
380 ...common,
381 label:
382 title ??
383 (item.changes !== undefined && item.changes.length > 1
384 ? `Changed ${item.changes.length} files`
385 : `Changed ${item.fileName}`),
386 changedFiles: item.changes?.map((change) => change.path) ?? [item.fileName],
387 toolTitle: title ?? "File change",
388 toolData: item,
389 };
390 }
391 case "file_search":
392 return {
393 ...common,
394 label: title ?? formatSearchToolLabel(item) ?? "Searched files",
395 ...(item.pattern ? { detail: item.pattern } : {}),
396 toolTitle: title ?? "File search",
397 toolData: item,
398 };
399 case "web_search":
400 return {
401 ...common,
402 label: title ?? "Searched the web",
403 ...(item.patterns?.length ? { detail: item.patterns.join(", ") } : {}),
404 toolTitle: title ?? "Web search",
405 toolData: item,
406 };
407 case "checkpoint":
408 return {
409 ...common,
410 label: title ?? "Checkpoint captured",
411 changedFiles: item.files.map((file) => file.path),
412 toolData: item,
413 };
414 case "system_notice":
415 return {
416 ...common,
417 label: item.message,
418 sourceActivityKind: "runtime.warning",
419 };
420 case "error": {
421 const presentation = providerErrorPresentation(item);
422 return {
423 ...common,
424 ...presentation,
425 ...(item.failure.class === "usage_limit" && item.status !== "completed"
426 ? { sourceActivityKind: "runtime.warning" }
427 : item.retry === undefined
428 ? { sourceActivityKind: "runtime.error" }
429 : {}),
430 toolData: item,
431 };
432 }
433 case "dynamic_tool": {
434 const classified = classifyToolActivity({
435 itemType: "dynamic_tool_call",
436 data: { toolName: item.toolName ?? undefined, input: item.input },
437 });
438 const [readPath] = collectToolFilePaths({ input: item.input });
439 return {
440 ...common,
441 label:
442 title ??
443 (classified === "read"
444 ? formatReadToolLabel(readPath ?? "")
445 : classified === "search"
446 ? (formatSearchToolLabel({ input: item.input }) ?? item.toolName ?? "Tool call")
447 : (item.toolName ?? "Tool call")),
448 toolTitle: title ?? item.toolName ?? "Tool",
449 toolData: { input: item.input, output: item.output },
450 };
451 }
452 case "todo_list": {
453 // Each todo update is a quiet row where the agent wrote it; it opens to every step.
454 const done = item.steps.filter((step) => step.status === "completed").length;
455 const current =
456 item.steps.find((step) => step.status === "running") ??
457 item.steps.find((step) => step.status === "pending");
458 return {
459 ...common,
460 label: `Tasks ${done}/${item.steps.length}${current ? ` · ${current.text}` : ""}`,
461 };
462 }
463 case "approval_request":
464 return {
465 ...common,
466 label: title ?? "Approval requested",
467 detail: item.prompt ?? item.requestKind,
468 toolData: item,
469 };
470 case "user_input_request":
471 return {
472 ...common,
473 label: title ?? (item.questionAnswer ? "Answered questions" : "Input requested"),
474 ...(item.questionAnswer ? { questionAnswer: item.questionAnswer } : {}),
475 toolData: item,
476 };
477 default:
478 return {
479 ...common,
480 label: title ?? item.type.replaceAll("_", " "),
481 toolData: item,
482 };
483 }
484}
485
486/** Preserve visibleTurnItems' committed order; interleave client messages by timestamp. */
487export interface TimelineEntriesInput {
488 readonly visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
489 readonly optimisticMessages: ReadonlyArray<ChatMessage>;
490 readonly anchoredMessages?: ReadonlyArray<ChatMessage>;
491 readonly attachmentUrlById?: ReadonlyMap<string, string>;
492 readonly attempts?: ReadonlyArray<OrchestrationV2RunAttempt>;
493 readonly nodes?: ReadonlyArray<OrchestrationV2ExecutionNode>;
494 readonly plans?: ReadonlyArray<OrchestrationV2PlanArtifact>;
495}
496
497export interface TimelineEntriesProjection {
498 readonly input: TimelineEntriesInput;
499 readonly entries: TimelineEntry[];
500}
501
502export function deriveTimelineEntriesFromVisibleTurnItems(
503 input: TimelineEntriesInput,
504): TimelineEntry[] {
505 const committedMessageIds = new Set<string>();
506 const entries: TimelineEntry[] = [];
507 const attemptByRootNodeId = new Map(
508 (input.attempts ?? []).map((attempt) => [attempt.rootNodeId, attempt] as const),
509 );
510 const nodeById = new Map((input.nodes ?? []).map((node) => [node.id, node] as const));
511 const planById = new Map((input.plans ?? []).map((plan) => [plan.id, plan] as const));
512
513 const resolveAttempt = (item: OrchestrationV2TurnItem): TimelineAttempt | undefined => {
514 if (item.nodeId === null || item.runId === null) return undefined;
515 let nodeId: OrchestrationV2ExecutionNode["id"] | null = item.nodeId;
516 const visited = new Set<OrchestrationV2ExecutionNode["id"]>();
517 while (nodeId !== null && !visited.has(nodeId)) {
518 visited.add(nodeId);
519 const directAttempt = attemptByRootNodeId.get(nodeId);
520 if (directAttempt?.runId === item.runId) return directAttempt;
521 const node = nodeById.get(nodeId);
522 if (node === undefined) return undefined;
523 const rootAttempt = attemptByRootNodeId.get(node.rootNodeId);
524 if (rootAttempt?.runId === item.runId) return rootAttempt;
525 nodeId = node.parentNodeId;
526 }
527 return undefined;
528 };
529
530 // An answer that started a later run stays a normal message at that run's position.
531 const foldedAnswerRunIdByMessageId = new Map<string, OrchestrationV2TurnItem["runId"]>(
532 input.visibleTurnItems.flatMap(({ item }) =>
533 item.type === "user_input_request" && item.questionAnswer
534 ? [[`async-answer:${item.questionAnswer.requestId}`, item.runId] as const]
535 : [],
536 ),
537 );
538 for (const row of input.visibleTurnItems) {
539 const { item } = row;
540 if (turnItemIsWorkspacePreparation(item) && !item.deferredWork) continue;
541 if (item.type === "checkpoint") continue;
542 if (
543 item.type === "user_message" &&
544 item.inputIntent === "queued_turn" &&
545 item.status === "pending"
546 )
547 continue;
548 if (
549 item.type === "user_message" &&
550 foldedAnswerRunIdByMessageId.get(item.messageId) === item.runId
551 )
552 continue;
553 const createdAt = projectedItemCreatedAt(row);
554 const attempt = resolveAttempt(item);
555 const attemptMetadata = attempt === undefined ? {} : { attempt };
556 if (item.type === "notification") {
557 entries.push({
558 id: item.id,
559 kind: "work",
560 createdAt,
561 entry: {
562 id: item.id,
563 createdAt,
564 runId: item.runId,
565 label: item.summary,
566 tone: "info",
567 itemType: item.type,
568 structuredPayload: item,
569 projectedItem: row,
570 },
571 ...attemptMetadata,
572 });
573 continue;
574 }
575 if (item.type === "user_message" || item.type === "assistant_message") {
576 const message: ChatMessage = {
577 id: item.messageId,
578 role: item.type === "user_message" ? "user" : "assistant",
579 text: item.text,
580 ...(item.type === "user_message" && item.context ? { context: item.context } : {}),
581 ...((item.attachments?.length ?? 0) > 0
582 ? {
583 attachments: (item.attachments ?? []).map((attachment) => {
584 const previewUrl = input.attachmentUrlById?.get(attachment.id);
585 return previewUrl ? { ...attachment, previewUrl } : attachment;
586 }),
587 }
588 : {}),
589 runId: item.runId,
590 streaming: item.type === "assistant_message" && item.streaming,
591 ...(item.type === "user_message"
592 ? {
593 createdBy: item.createdBy,
594 creationSource: item.creationSource,
595 ...(item.senderThreadId !== undefined ? { senderThreadId: item.senderThreadId } : {}),
596 ...(item.linkedSender !== undefined ? { linkedSender: item.linkedSender } : {}),
597 ...(item.idleCompaction !== undefined ? { idleCompaction: item.idleCompaction } : {}),
598 ...(item.scheduledTaskId !== undefined
599 ? { scheduledTaskId: item.scheduledTaskId }
600 : {}),
601 }
602 : {}),
603 createdAt,
604 updatedAt: DateTime.formatIso(item.updatedAt),
605 ...(item.type === "user_message" ? { inputIntent: item.inputIntent } : {}),
606 };
607 committedMessageIds.add(message.id);
608 entries.push({
609 id: message.id,
610 kind: "message",
611 createdAt,
612 message,
613 projectedItem: row,
614 ...attemptMetadata,
615 });
616 continue;
617 }
618
619 if (item.type === "proposed_plan") {
620 const plan = planById.get(item.planId);
621 const proposedPlan = {
622 id: item.planId,
623 runId: item.runId,
624 planMarkdown: item.markdown,
625 status: plan?.kind === "proposed_plan" ? plan.status : ("active" as const),
626 createdAt,
627 updatedAt: DateTime.formatIso(item.updatedAt),
628 };
629 entries.push({
630 id: item.id,
631 kind: "proposed-plan",
632 createdAt,
633 proposedPlan,
634 ...attemptMetadata,
635 });
636 continue;
637 }
638
639 const htmlRender =
640 item.type === "dynamic_tool" && item.status === "completed"
641 ? htmlRenderFromToolItem(item)
642 : undefined;
643 if (htmlRender !== undefined) {
644 entries.push({
645 id: item.id,
646 kind: "html-render",
647 createdAt,
648 runId: item.runId,
649 htmlRender,
650 ...attemptMetadata,
651 });
652 continue;
653 }
654
655 const mcpApp = item.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined;
656 if (mcpApp !== undefined) {
657 entries.push({
658 id: item.id,
659 kind: "mcp-app",
660 createdAt,
661 runId: item.runId,
662 sourceThreadId: row.sourceThreadId,
663 itemId: row.sourceItemId,
664 revision: turnItemDetailRevision(item),
665 mcpApp,
666 ...attemptMetadata,
667 });
668 continue;
669 }
670
671 if (STANDALONE_V2_ITEM_TYPES.has(item.type)) {
672 entries.push({
673 id: item.id,
674 kind: "event",
675 createdAt,
676 projectedItem: row,
677 ...attemptMetadata,
678 });
679 continue;
680 }
681
682 entries.push({
683 id: item.id,
684 kind: "work",
685 createdAt,
686 entry: projectedWorkEntry(row),
687 ...attemptMetadata,
688 });
689 }
690
691 const retainedMessageIds = new Set([
692 ...committedMessageIds,
693 ...foldedAnswerRunIdByMessageId.keys(),
694 ]);
695 for (const message of input.anchoredMessages ?? []) {
696 if (retainedMessageIds.has(message.id)) continue;
697 retainedMessageIds.add(message.id);
698 const entry: TimelineEntry = {
699 id: message.id,
700 kind: "message",
701 createdAt: message.createdAt,
702 message,
703 };
704 const insertionIndex = entries.findIndex(
705 (candidate) => candidate.createdAt > message.createdAt,
706 );
707 if (insertionIndex === -1) {
708 entries.push(entry);
709 } else {
710 entries.splice(insertionIndex, 0, entry);
711 }
712 }
713
714 for (const message of input.optimisticMessages) {
715 if (message.inputIntent !== "queued_turn" && !retainedMessageIds.has(message.id)) {
716 retainedMessageIds.add(message.id);
717 entries.push({
718 id: message.id,
719 kind: "message",
720 createdAt: message.createdAt,
721 message,
722 });
723 }
724 }
725
726 return entries;
727}
728
729type AttachmentResource = Extract<AssetResource, { readonly _tag: "attachment" }>;
730const EMPTY_IMAGE_RESOURCES = Object.freeze<ReadonlyArray<AttachmentResource>>([]);
731
732export function selectMessageImageResources(
733 attachments: ChatMessage["attachments"],
734): ReadonlyArray<AttachmentResource> {
735 const attachmentIds = new Set<string>();
736 for (const attachment of attachments ?? []) {
737 if (!isImageAttachment(attachment)) continue;
738 const previewUrl = attachment.previewUrl;
739 if (previewUrl?.startsWith("blob:") || previewUrl?.startsWith("data:")) continue;
740 attachmentIds.add(attachment.id);
741 }
742 return attachmentIds.size === 0
743 ? EMPTY_IMAGE_RESOURCES
744 : Array.from(attachmentIds, (attachmentId) => ({ _tag: "attachment", attachmentId }));
745}
746
747export function selectHandoffImageResources(
748 messages: ReadonlyArray<Pick<ChatMessage, "id" | "role" | "attachments">> | undefined,
749 handoffs: Readonly<Record<string, ReadonlyArray<string>>>,
750): ReadonlyArray<AttachmentResource> {
751 if (Object.keys(handoffs).length === 0) return EMPTY_IMAGE_RESOURCES;
752 const attachmentIds = new Set<string>();
753 for (const message of messages ?? []) {
754 if (message.role !== "user" || !handoffs[message.id]?.length) continue;
755 for (const attachment of message.attachments ?? []) {
756 if (isImageAttachment(attachment)) attachmentIds.add(attachment.id);
757 }
758 }
759 return attachmentIds.size === 0
760 ? EMPTY_IMAGE_RESOURCES
761 : Array.from(attachmentIds, (attachmentId) => ({ _tag: "attachment", attachmentId }));
762}
763
764export function createMessageAttachmentPreviewProjector() {
765 const attachmentsBySource = new WeakMap<
766 ReadonlyArray<ChatAttachment>,
767 ReadonlyArray<ChatAttachment>
768 >();
769 const messagesBySource = new WeakMap<ChatMessage, ChatMessage>();
770 return (
771 message: ChatMessage,
772 previewUrlFor: (attachment: ChatAttachment) => string | undefined,
773 ): ChatMessage => {
774 const source = message.attachments;
775 if (!source || source.length === 0) return message;
776 const previous = attachmentsBySource.get(source) ?? source;
777 let changed: ChatAttachment[] | undefined;
778 let hasOverrides = false;
779 for (const [index, attachment] of source.entries()) {
780 const previewUrl = previewUrlFor(attachment);
781 const sourceUrl = "previewUrl" in attachment ? attachment.previewUrl : undefined;
782 const previousAttachment = previous[index]!;
783 const previousUrl =
784 "previewUrl" in previousAttachment ? previousAttachment.previewUrl : undefined;
785 const next =
786 !previewUrl || previewUrl === sourceUrl
787 ? attachment
788 : previewUrl === previousUrl
789 ? previousAttachment
790 : { ...attachment, previewUrl };
791 hasOverrides ||= next !== attachment;
792 if (next !== previousAttachment) {
793 changed ??= previous.slice();
794 changed[index] = next;
795 }
796 }
797 const attachments = hasOverrides ? (changed ?? previous) : source;
798 attachmentsBySource.set(source, attachments);
799 if (attachments === source) {
800 messagesBySource.delete(message);
801 return message;
802 }
803 const previousMessage = messagesBySource.get(message);
804 if (previousMessage?.attachments === attachments) return previousMessage;
805 const result = { ...message, attachments };
806 messagesBySource.set(message, result);
807 return result;
808 };
809}
810
811export function isStreamingMessageTextUpdate(previous: ChatMessage, next: ChatMessage): boolean {
812 if (
813 previous.role !== "assistant" ||
814 next.role !== "assistant" ||
815 !previous.streaming ||
816 !next.streaming
817 ) {
818 return false;
819 }
820 const { text: _previousText, updatedAt: _previousUpdatedAt, ...previousMetadata } = previous;
821 const { text: _nextText, updatedAt: _nextUpdatedAt, ...nextMetadata } = next;
822 return shallow(previousMetadata, nextMetadata);
823}
824
825function streamedTextMetadata(item: OrchestrationV2TurnItem) {
826 if (item.type === "proposed_plan" && item.streaming) {
827 const { markdown: _markdown, updatedAt: _updatedAt, ...metadata } = item;
828 return metadata;
829 }
830 if ((item.type === "assistant_message" || item.type === "reasoning") && item.streaming) {
831 const { text: _text, updatedAt: _updatedAt, ...metadata } = item;
832 return metadata;
833 }
834 return null;
835}
836
837export function isStreamingTurnItemTextUpdate(
838 previous: OrchestrationV2ProjectedTurnItem,
839 next: OrchestrationV2ProjectedTurnItem,
840): boolean {
841 const { item: previousItem, ...previousSource } = previous;
842 const { item: nextItem, ...nextSource } = next;
843 const previousMetadata = streamedTextMetadata(previousItem);
844 const nextMetadata = streamedTextMetadata(nextItem);
845 if (
846 previousMetadata === null ||
847 nextMetadata === null ||
848 !shallow(previousSource, nextSource) ||
849 projectedItemCreatedAt(previous) !== projectedItemCreatedAt(next)
850 ) {
851 return false;
852 }
853 // Wire decoding recreates timestamps and attachments; compare only the changed item's metadata.
854 return Equal.equals(previousMetadata, nextMetadata);
855}
856
857/**
858 * A changed tool or work item maps to exactly one entry with the item's id.
859 * Messages and question answers take part in cross-entry dedupe, and anchored
860 * messages are placed by createdAt, so those changes rebuild.
861 */
862function canRederiveInPlace(
863 input: TimelineEntriesInput,
864 previousItem: OrchestrationV2ProjectedTurnItem,
865 item: OrchestrationV2ProjectedTurnItem,
866): boolean {
867 return (
868 item.item.id === previousItem.item.id &&
869 item.item.type === previousItem.item.type &&
870 item.item.type !== "user_message" &&
871 item.item.type !== "assistant_message" &&
872 item.item.type !== "user_input_request" &&
873 ((input.anchoredMessages?.length ?? 0) === 0 ||
874 projectedItemCreatedAt(item) === projectedItemCreatedAt(previousItem))
875 );
876}
877
878/** Entries read only each plan's status; streaming plans rebuild artifacts with every delta. */
879function samePlanStatuses(
880 next: TimelineEntriesInput["plans"],
881 previous: TimelineEntriesInput["plans"],
882): boolean {
883 if (next === previous) return true;
884 if (next === undefined || previous === undefined || next.length !== previous.length) return false;
885 return next.every((plan, index) => {
886 const before = previous[index]!;
887 return plan.id === before.id && plan.kind === before.kind && plan.status === before.status;
888 });
889}
890
891function reuseTimelineEntries(
892 input: TimelineEntriesInput,
893 previous: TimelineEntriesProjection,
894): TimelineEntry[] | null {
895 const before = previous.input;
896 if (
897 input.visibleTurnItems.length < before.visibleTurnItems.length ||
898 !shallow(input.optimisticMessages, before.optimisticMessages) ||
899 !shallow(input.anchoredMessages, before.anchoredMessages) ||
900 !shallow(input.attachmentUrlById, before.attachmentUrlById) ||
901 !shallow(input.attempts, before.attempts) ||
902 !shallow(input.nodes, before.nodes) ||
903 !samePlanStatuses(input.plans, before.plans)
904 ) {
905 return null;
906 }
907 const appended = input.visibleTurnItems.length > before.visibleTurnItems.length;
908 // Keep full projection until committed items interleave and deduplicate anchored/optimistic messages.
909 if (
910 appended &&
911 (input.optimisticMessages.length > 0 || (input.anchoredMessages?.length ?? 0) > 0)
912 ) {
913 return null;
914 }
915 // Answer rows can replace a message already present in the retained prefix.
916 if (
917 appended &&
918 input.visibleTurnItems
919 .slice(before.visibleTurnItems.length)
920 .some(
921 ({ item }) =>
922 (item.type === "user_input_request" && item.questionAnswer !== undefined) ||
923 (item.type === "user_message" && item.messageId.startsWith("async-answer:")),
924 )
925 )
926 return null;
927 const replacements = new Map<
928 OrchestrationV2ProjectedTurnItem,
929 OrchestrationV2ProjectedTurnItem
930 >();
931 const changedItems: OrchestrationV2ProjectedTurnItem[] = [];
932 for (let index = 0; index < before.visibleTurnItems.length; index += 1) {
933 const previousItem = before.visibleTurnItems[index]!;
934 const item = input.visibleTurnItems[index]!;
935 if (item === previousItem) continue;
936 // Reasoning and plan text re-derive below; only assistant text patches the message.
937 if (
938 item.item.type === "assistant_message" &&
939 isStreamingTurnItemTextUpdate(previousItem, item)
940 ) {
941 replacements.set(previousItem, item);
942 continue;
943 }
944 if (!canRederiveInPlace(input, previousItem, item)) return null;
945 changedItems.push(item);
946 }
947 // One batch keeps the attempt/node/plan lookups to a single build.
948 const rederived =
949 changedItems.length === 0
950 ? []
951 : deriveTimelineEntriesFromVisibleTurnItems({
952 ...input,
953 visibleTurnItems: changedItems,
954 optimisticMessages: [],
955 anchoredMessages: [],
956 });
957 if (
958 rederived.length !== changedItems.length ||
959 rederived.some((entry, index) => entry.id !== changedItems[index]!.item.id)
960 ) {
961 return null;
962 }
963 const rederivedEntries = new Map(rederived.map((entry) => [entry.id, entry]));
964 if (replacements.size === 0 && rederivedEntries.size === 0 && !appended) {
965 return previous.entries;
966 }
967 let rederivedCount = 0;
968 const entries = previous.entries.map((entry): TimelineEntry => {
969 const rederivedEntry = rederivedEntries.get(entry.id);
970 if (rederivedEntry !== undefined) {
971 rederivedCount += 1;
972 return rederivedEntry;
973 }
974 const row =
975 entry.kind === "message" && entry.projectedItem !== undefined
976 ? replacements.get(entry.projectedItem)
977 : undefined;
978 if (entry.kind !== "message" || row?.item.type !== "assistant_message") return entry;
979 return {
980 ...entry,
981 projectedItem: row,
982 message: {
983 ...entry.message,
984 text: row.item.text,
985 updatedAt: DateTime.formatIso(row.item.updatedAt),
986 },
987 };
988 });
989 if (rederivedCount !== rederivedEntries.size) return null;
990 if (appended) {
991 entries.push(
992 ...deriveTimelineEntriesFromVisibleTurnItems({
993 ...input,
994 visibleTurnItems: input.visibleTurnItems.slice(before.visibleTurnItems.length),
995 }),
996 );
997 }
998 return entries;
999}
1000
1001export function deriveTimelineEntriesFromVisibleTurnItemsWithState(
1002 input: TimelineEntriesInput,
1003 previous: TimelineEntriesProjection | null = null,
1004): TimelineEntriesProjection {
1005 const reused = previous === null ? null : reuseTimelineEntries(input, previous);
1006 if (reused !== null) return { input, entries: reused };
1007 const entries = deriveTimelineEntriesFromVisibleTurnItems(input);
1008 if (previous === null || !shallow(input.attachmentUrlById, previous.input.attachmentUrlById)) {
1009 return { input, entries };
1010 }
1011 const previousMessages = new Map(
1012 previous.entries.flatMap((entry) =>
1013 entry.kind === "message" ? [[entry.id, entry] as const] : [],
1014 ),
1015 );
1016 return {
1017 input,
1018 entries: entries.map((entry) => {
1019 if (entry.kind !== "message" || entry.projectedItem === undefined) return entry;
1020 const before = previousMessages.get(entry.id);
1021 if (before?.projectedItem !== entry.projectedItem) return entry;
1022 return before.attempt === entry.attempt ? before : { ...entry, message: before.message };
1023 }),
1024 };
1025}
1026
1027// Run ordinals keep increasing after a rewind; only visible local runs define predecessors.
1028export function deriveRevertTurnCountByUserMessageId(input: {
1029 readonly timelineEntries: ReadonlyArray<TimelineEntry>;
1030 readonly checkpoints: ReadonlyArray<ThreadCheckpointSummary>;
1031 readonly threadStartCheckpointAvailable: boolean;
1032 readonly availableRevertTurnCounts?: ReadonlySet<number> | undefined;
1033 readonly unsettledRunId?: RunId | null;
1034}): Map<ChatMessage["id"], number> {
1035 const turnCountByRunId = new Map<RunId, number>();
1036 for (const checkpoint of input.checkpoints) {
1037 if (isRewindableCheckpoint(checkpoint)) {
1038 turnCountByRunId.set(checkpoint.runId, checkpoint.checkpointTurnCount);
1039 }
1040 }
1041 const targetByRunId = new Map<RunId, number | undefined>();
1042 const byUserMessageId = new Map<ChatMessage["id"], number>();
1043 let previousRunId: RunId | null = null;
1044 for (const entry of input.timelineEntries) {
1045 // Inherited history belongs to the source thread, not our checkpoint scope.
1046 if (entry.kind !== "message" && entry.kind !== "work") continue;
1047 const projectedItem =
1048 entry.kind === "message" ? entry.projectedItem : entry.entry.projectedItem;
1049 if (projectedItem?.visibility === "inherited") continue;
1050 const runId =
1051 entry.kind === "message"
1052 ? entry.message.runId
1053 : entry.entry.itemType === "notification"
1054 ? entry.entry.runId
1055 : null;
1056 if (runId == null) continue;
1057 if (!targetByRunId.has(runId)) {
1058 targetByRunId.set(
1059 runId,
1060 previousRunId === null
1061 ? input.threadStartCheckpointAvailable
1062 ? 0
1063 : undefined
1064 : turnCountByRunId.get(previousRunId),
1065 );
1066 previousRunId = runId;
1067 }
1068 if (entry.kind !== "message") continue;
1069 const { message } = entry;
1070 if (message.role !== "user" || message.runId === input.unsettledRunId) continue;
1071 if (message.inputIntent !== "turn_start" && message.inputIntent !== "queued_turn") continue;
1072 const target = targetByRunId.get(runId);
1073 if (
1074 target !== undefined &&
1075 (input.availableRevertTurnCounts === undefined || input.availableRevertTurnCounts.has(target))
1076 )
1077 byUserMessageId.set(message.id, target);
1078 }
1079 return byUserMessageId;
1080}
1081
1082// A missing snapshot (no git, or a side chat) still pins a conversation position; only
1083// restoring files needs a ready one.
1084function isRewindableCheckpoint(checkpoint: Pick<OrchestrationV2Checkpoint, "status">) {
1085 return checkpoint.status === "ready" || checkpoint.status === "missing";
1086}
1087
1088export function findRevertCheckpoint(
1089 checkpoints: ReadonlyArray<OrchestrationV2Checkpoint>,
1090 turnCount: number,
1091): OrchestrationV2Checkpoint | undefined {
1092 return checkpoints.findLast(
1093 (checkpoint) =>
1094 isRewindableCheckpoint(checkpoint) &&
1095 (turnCount === 0
1096 ? checkpoint.ordinalWithinScope === 0 && checkpoint.appRunOrdinal === null
1097 : checkpoint.appRunOrdinal === turnCount),
1098 );
1099}
1100
1101export function derivePhase(runtime: ThreadStructureRuntime | null): SessionPhase {
1102 if (runtime === null) return "disconnected";
1103 if (
1104 runtime.status === "preparing" ||
1105 runtime.status === "starting" ||
1106 runtime.status === "queued"
1107 )
1108 return "connecting";
1109 if (runtime.status === "running" || runtime.status === "waiting") return "running";
1110 return "ready";
1111}
1112
1113/** Preparing and starting runs can be interrupted; Stop need not wait for running. */
1114export function deriveCanInterruptRunningThread(
1115 hasActiveThread: boolean,
1116 runtime: ThreadStructureRuntime | null,
1117): boolean {
1118 return (
1119 hasActiveThread &&
1120 (derivePhase(runtime) === "running" || threadRuntimeHasInterruptibleRun(runtime))
1121 );
1122}
1123
1124export type { TurnDiffSummary };