1import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models";
2import {
3 type AssetResource,
4 type OrchestrationV2ExecutionNode,
5 type OrchestrationV2PlanArtifact,
6 type OrchestrationV2ProjectedTurnItem,
7 type OrchestrationV2RunAttempt,
8 type OrchestrationV2ThreadProjection,
9 type OrchestrationV2TurnItem,
10 type PlanId,
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 interface ActivePlanState {
101 readonly createdAt: string;
102 readonly runId: RunId | null;
103 readonly explanation?: string | null;
104 readonly steps: Array<{
105 readonly step: string;
106 readonly status: "pending" | "inProgress" | "completed";
107 readonly durationMs?: number;
108 }>;
109}
110
111export interface LatestProposedPlanState {
112 readonly id: PlanId;
113 readonly createdAt: string;
114 readonly updatedAt: string;
115 readonly runId: RunId | null;
116 readonly planMarkdown: string;
117 readonly status: OrchestrationV2PlanArtifact["status"];
118}
119
120export type TimelineAttempt = Pick<
121 OrchestrationV2RunAttempt,
122 "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "completedAt"
123>;
124
125export type TimelineEntry = (
126 | {
127 readonly id: string;
128 readonly kind: "message";
129 readonly createdAt: string;
130 readonly message: ChatMessage;
131 readonly projectedItem?: OrchestrationV2ProjectedTurnItem;
132 }
133 | {
134 readonly id: string;
135 readonly kind: "proposed-plan";
136 readonly createdAt: string;
137 readonly proposedPlan: ProposedPlan;
138 }
139 | {
140 /** A page a completed `html_render` call published, shown where the call happened. */
141 readonly id: string;
142 readonly kind: "html-render";
143 readonly createdAt: string;
144 readonly runId: RunId | null;
145 readonly htmlRender: HtmlRenderReference;
146 }
147 | {
148 /** An MCP App a completed tool call captured, hosted where the call happened. */
149 readonly id: string;
150 readonly kind: "mcp-app";
151 readonly createdAt: string;
152 readonly runId: RunId | null;
153 /** The thread and item that own the app; a fork's inherited app is its source's. */
154 readonly sourceThreadId: ThreadId;
155 readonly itemId: TurnItemId;
156 readonly revision: string;
157 readonly mcpApp: McpAppReference;
158 }
159 | {
160 readonly id: string;
161 readonly kind: "work";
162 readonly createdAt: string;
163 readonly entry: WorkLogEntry;
164 }
165 | {
166 readonly id: string;
167 readonly kind: "event";
168 readonly createdAt: string;
169 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
170 }
171) & {
172 /** V2 identity resolved from the item's execution node, when locally available. */
173 readonly attempt?: TimelineAttempt;
174};
175
176export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
177 return (
178 entry.tone === "tool" ||
179 entry.tone === "thinking" ||
180 entry.tone === "error" ||
181 entry.command !== undefined ||
182 entry.requestKind !== undefined
183 );
184}
185
186/** Severe failures keep the red treatment ordinary tool failures lost: provider
187 * runtime errors mean the turn or a core side effect broke, not that a
188 * command exited nonzero. */
189export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean {
190 return entry.itemType === "error";
191}
192
193export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
194 if (
195 !workLogEntryIsToolLike(entry) ||
196 workEntryIndicatesToolFailure(entry) ||
197 (entry.tone === "thinking" && entry.itemType !== "reasoning")
198 ) {
199 return false;
200 }
201 const status = entry.toolLifecycleStatus;
202 return (
203 status !== "failed" &&
204 status !== "declined" &&
205 status !== "inProgress" &&
206 status !== "stopped" &&
207 status !== "idle"
208 );
209}
210
211/** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */
212export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {
213 return (
214 workLogEntryIsToolLike(entry) &&
215 !workEntryIndicatesToolFailure(entry) &&
216 !workEntryIndicatesToolSuccess(entry)
217 );
218}
219
220export function isLatestRunSettled(
221 latestRun: Pick<ThreadRunSummary, "runId" | "startedAt" | "completedAt" | "status"> | null,
222 runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId"> | null,
223): boolean {
224 if (latestRun === null) return false;
225 if (
226 latestRun.status === "preparing" ||
227 latestRun.status === "queued" ||
228 latestRun.status === "starting" ||
229 latestRun.status === "running" ||
230 latestRun.status === "waiting"
231 )
232 return false;
233 return runtime?.activeRunId !== latestRun.runId;
234}
235
236export function deriveActiveWorkStartedAt(
237 latestRun: Pick<
238 ThreadRunSummary,
239 "runId" | "startedAt" | "requestedAt" | "completedAt" | "status"
240 > | null,
241 runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId" | "activityStartedAt"> | null,
242 sendStartedAt: string | null,
243): string | null {
244 const startedAt = resolveThreadWorkingStartedAt({ latestRun, runtime });
245 // Local dispatch has a clock only until the server supplies the owning run.
246 return startedAt ?? (runtime?.activeRunId == null ? sendStartedAt : null);
247}
248
249export function derivePendingApprovals(
250 approvals: ReadonlyArray<ThreadPendingApproval>,
251): ThreadPendingApproval[] {
252 return [...approvals].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
253}
254
255export function derivePendingUserInputs(
256 inputs: ReadonlyArray<ThreadPendingUserInput>,
257): ThreadPendingUserInput[] {
258 return [...inputs].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
259}
260
261export function deriveActivePlanState(
262 projection: OrchestrationV2ThreadProjection | null,
263 latestRunId: RunId | undefined,
264): ActivePlanState | null {
265 if (projection === null) return null;
266 const plans = projection.plans.filter((plan) => plan.kind === "todo_list");
267 const plan =
268 [...plans].toReversed().find((candidate) => candidate.runId === latestRunId) ??
269 plans.at(-1) ??
270 null;
271 if (plan === null || plan.steps.length === 0) return null;
272 return {
273 createdAt: planItemTime(projection, plan.id),
274 runId: plan.runId,
275 explanation: plan.explanation ?? null,
276 steps: plan.steps.map(({ text, status, durationMs }) => ({
277 step: text,
278 status: status === "running" ? "inProgress" : status,
279 ...(durationMs === undefined ? {} : { durationMs }),
280 })),
281 };
282}
283
284function planItemTime(projection: OrchestrationV2ThreadProjection, planId: PlanId): string {
285 const item = projection.turnItems.findLast(
286 (candidate) =>
287 (candidate.type === "proposed_plan" || candidate.type === "todo_list") &&
288 candidate.planId === planId,
289 );
290 return DateTime.formatIso(item?.updatedAt ?? projection.updatedAt);
291}
292
293function toLatestProposedPlanState(
294 projection: OrchestrationV2ThreadProjection,
295 plan: Extract<OrchestrationV2PlanArtifact, { readonly kind: "proposed_plan" }>,
296): LatestProposedPlanState {
297 const updatedAt = planItemTime(projection, plan.id);
298 return {
299 id: plan.id,
300 createdAt: updatedAt,
301 updatedAt,
302 runId: plan.runId,
303 planMarkdown: plan.markdown,
304 status: plan.status,
305 };
306}
307
308export function findLatestProposedPlan(
309 projection: OrchestrationV2ThreadProjection | null,
310 latestRunId: RunId | string | null | undefined,
311): LatestProposedPlanState | null {
312 if (projection === null) return null;
313 const plans = projection.plans.filter((plan) => plan.kind === "proposed_plan");
314 const candidates = latestRunId ? plans.filter((plan) => plan.runId === latestRunId) : plans;
315 const plan = [...(candidates.length > 0 ? candidates : plans)]
316 .toSorted(
317 (left, right) =>
318 planItemTime(projection, left.id).localeCompare(planItemTime(projection, right.id)) ||
319 left.id.localeCompare(right.id),
320 )
321 .at(-1);
322 return plan === undefined ? null : toLatestProposedPlanState(projection, plan);
323}
324
325export function hasActionableProposedPlan(plan: LatestProposedPlanState | null): boolean {
326 return plan?.status === "active";
327}
328
329const STANDALONE_V2_ITEM_TYPES = new Set<OrchestrationV2ProjectedTurnItem["item"]["type"]>([
330 "fork",
331 "handoff",
332 "run_interrupt_request",
333 "run_interrupt_result",
334 "secret_request",
335 "subagent",
336]);
337
338const PERSISTENT_RESOURCE_V2_ITEM_TYPES = new Set<OrchestrationV2TurnItem["type"]>([
339 "fork",
340 "thread_created",
341 // Still answerable after a steer supersedes the attempt that asked.
342 "secret_request",
343]);
344
345export function timelineEntryIsPersistentResourceCard(entry: TimelineEntry): boolean {
346 return (
347 entry.kind === "event" && PERSISTENT_RESOURCE_V2_ITEM_TYPES.has(entry.projectedItem.item.type)
348 );
349}
350
351function projectedItemCreatedAt(row: OrchestrationV2ProjectedTurnItem): string {
352 return DateTime.formatIso(row.item.startedAt ?? row.item.updatedAt);
353}
354
355function projectedWorkEntryStatus(
356 item: OrchestrationV2TurnItem,
357): NonNullable<WorkLogEntry["toolLifecycleStatus"]> {
358 switch (item.status) {
359 case "pending":
360 case "running":
361 case "waiting":
362 return "inProgress";
363 case "completed":
364 return "completed";
365 case "idle":
366 return "idle";
367 case "failed":
368 return "failed";
369 case "cancelled":
370 case "interrupted":
371 return "stopped";
372 }
373}
374
375function projectedWorkEntryTone(item: OrchestrationV2TurnItem): WorkLogEntry["tone"] {
376 if (item.type === "error") return "info";
377 if (item.type === "reasoning") return "thinking";
378 switch (item.type) {
379 case "command_execution":
380 case "file_change":
381 case "file_search":
382 case "web_search":
383 case "dynamic_tool":
384 case "subagent":
385 case "thread_created":
386 case "user_input_request":
387 case "approval_request":
388 return "tool";
389 default:
390 return "info";
391 }
392}
393
394export function providerErrorPresentation(
395 item: Extract<OrchestrationV2TurnItem, { readonly type: "error" }>,
396): { readonly label: string; readonly detail: string } {
397 if (item.retry === undefined) {
398 return {
399 label:
400 item.failure.class === "usage_limit"
401 ? "Usage limit reached"
402 : item.title?.trim() || "Provider error",
403 detail: item.failure.message,
404 };
405 }
406 const progress =
407 item.retry.maxAttempts === null
408 ? `${item.retry.attempt}`
409 : `${item.retry.attempt}/${item.retry.maxAttempts}`;
410 const label =
411 item.status === "running"
412 ? `Retrying provider (${progress})`
413 : item.status === "completed"
414 ? `Provider recovered (${progress} retries)`
415 : item.status === "failed"
416 ? `${item.failure.class === "usage_limit" ? "Usage limit reached" : "Provider error"} after ${progress} retries`
417 : `Provider retry stopped (${progress})`;
418 const retryDelay =
419 item.status === "running" && item.retry.retryDelayMs !== null && item.retry.retryDelayMs > 0
420 ? item.retry.retryDelayMs < 1_000
421 ? ` Retrying in ${item.retry.retryDelayMs}ms.`
422 : ` Retrying in ${(item.retry.retryDelayMs / 1_000).toFixed(1).replace(/\.0$/u, "")}s.`
423 : "";
424 return {
425 label,
426 detail: `${item.failure.message}${retryDelay}`,
427 };
428}
429
430function projectedWorkEntry(row: OrchestrationV2ProjectedTurnItem): WorkLogEntry {
431 const { item } = row;
432 const title = item.title?.trim() || null;
433 const common = {
434 id: item.id,
435 createdAt: projectedItemCreatedAt(row),
436 runId: item.runId,
437 tone: projectedWorkEntryTone(item),
438 itemType: item.type,
439 toolLifecycleStatus: projectedWorkEntryStatus(item),
440 structuredPayload: item,
441 projectedItem: row,
442 ...extractToolActivityPresentation(item),
443 } as const;
444
445 switch (item.type) {
446 case "thread_created":
447 return {
448 ...common,
449 label: "Created thread",
450 };
451 case "compaction":
452 return {
453 ...common,
454 label: contextCompactionLabel(item),
455 sourceActivityKind: "context-compaction",
456 ...(item.summary ? { detail: item.summary } : {}),
457 };
458 case "reasoning":
459 return {
460 ...common,
461 label: title ?? "Thinking",
462 ...(item.text ? { detail: item.text } : {}),
463 };
464 case "command_execution":
465 return {
466 ...common,
467 label: title ?? "Ran command",
468 command: item.input,
469 rawCommand: item.input,
470 toolTitle: title ?? "Command",
471 toolData: item,
472 };
473 case "file_change": {
474 return {
475 ...common,
476 label:
477 title ??
478 (item.changes !== undefined && item.changes.length > 1
479 ? `Changed ${item.changes.length} files`
480 : `Changed ${item.fileName}`),
481 changedFiles: item.changes?.map((change) => change.path) ?? [item.fileName],
482 toolTitle: title ?? "File change",
483 toolData: item,
484 };
485 }
486 case "file_search":
487 return {
488 ...common,
489 label: title ?? formatSearchToolLabel(item) ?? "Searched files",
490 ...(item.pattern ? { detail: item.pattern } : {}),
491 toolTitle: title ?? "File search",
492 toolData: item,
493 };
494 case "web_search":
495 return {
496 ...common,
497 label: title ?? "Searched the web",
498 ...(item.patterns?.length ? { detail: item.patterns.join(", ") } : {}),
499 toolTitle: title ?? "Web search",
500 toolData: item,
501 };
502 case "checkpoint":
503 return {
504 ...common,
505 label: title ?? "Checkpoint captured",
506 changedFiles: item.files.map((file) => file.path),
507 toolData: item,
508 };
509 case "system_notice":
510 return {
511 ...common,
512 label: item.message,
513 sourceActivityKind: "runtime.warning",
514 };
515 case "error": {
516 const presentation = providerErrorPresentation(item);
517 return {
518 ...common,
519 ...presentation,
520 ...(item.failure.class === "usage_limit" && item.status !== "completed"
521 ? { sourceActivityKind: "runtime.warning" }
522 : item.retry === undefined
523 ? { sourceActivityKind: "runtime.error" }
524 : {}),
525 toolData: item,
526 };
527 }
528 case "dynamic_tool": {
529 const classified = classifyToolActivity({
530 itemType: "dynamic_tool_call",
531 data: { toolName: item.toolName ?? undefined, input: item.input },
532 });
533 const [readPath] = collectToolFilePaths({ input: item.input });
534 return {
535 ...common,
536 label:
537 title ??
538 (classified === "read"
539 ? formatReadToolLabel(readPath ?? "")
540 : classified === "search"
541 ? (formatSearchToolLabel({ input: item.input }) ?? item.toolName ?? "Tool call")
542 : (item.toolName ?? "Tool call")),
543 toolTitle: title ?? item.toolName ?? "Tool",
544 toolData: { input: item.input, output: item.output },
545 };
546 }
547 case "approval_request":
548 return {
549 ...common,
550 label: title ?? "Approval requested",
551 detail: item.prompt ?? item.requestKind,
552 toolData: item,
553 };
554 case "user_input_request":
555 return {
556 ...common,
557 label: title ?? (item.questionAnswer ? "Answered questions" : "Input requested"),
558 ...(item.questionAnswer ? { questionAnswer: item.questionAnswer } : {}),
559 toolData: item,
560 };
561 default:
562 return {
563 ...common,
564 label: title ?? item.type.replaceAll("_", " "),
565 toolData: item,
566 };
567 }
568}
569
570/**
571 * Builds the web timeline in the exact order committed by `visibleTurnItems`.
572 * Committed rows are presented directly from their projected item. Queued
573 * input is absent by construction until dispatch creates its user turn item.
574 * Persistent client-owned messages are inserted by timestamp without sorting
575 * the canonical sequence. True optimistic sends remain appended afterward.
576 */
577export interface TimelineEntriesInput {
578 readonly visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
579 readonly optimisticMessages: ReadonlyArray<ChatMessage>;
580 readonly anchoredMessages?: ReadonlyArray<ChatMessage>;
581 readonly attachmentUrlById?: ReadonlyMap<string, string>;
582 readonly attempts?: ReadonlyArray<OrchestrationV2RunAttempt>;
583 readonly nodes?: ReadonlyArray<OrchestrationV2ExecutionNode>;
584 readonly plans?: ReadonlyArray<OrchestrationV2PlanArtifact>;
585}
586
587export interface TimelineEntriesProjection {
588 readonly input: TimelineEntriesInput;
589 readonly entries: TimelineEntry[];
590}
591
592export function deriveTimelineEntriesFromVisibleTurnItems(
593 input: TimelineEntriesInput,
594): TimelineEntry[] {
595 const committedMessageIds = new Set<string>();
596 const entries: TimelineEntry[] = [];
597 const attemptByRootNodeId = new Map(
598 (input.attempts ?? []).map((attempt) => [attempt.rootNodeId, attempt] as const),
599 );
600 const nodeById = new Map((input.nodes ?? []).map((node) => [node.id, node] as const));
601 const planById = new Map((input.plans ?? []).map((plan) => [plan.id, plan] as const));
602
603 const resolveAttempt = (item: OrchestrationV2TurnItem): TimelineAttempt | undefined => {
604 if (item.nodeId === null || item.runId === null) return undefined;
605 let nodeId: OrchestrationV2ExecutionNode["id"] | null = item.nodeId;
606 const visited = new Set<OrchestrationV2ExecutionNode["id"]>();
607 while (nodeId !== null && !visited.has(nodeId)) {
608 visited.add(nodeId);
609 const directAttempt = attemptByRootNodeId.get(nodeId);
610 if (directAttempt?.runId === item.runId) return directAttempt;
611 const node = nodeById.get(nodeId);
612 if (node === undefined) return undefined;
613 const rootAttempt = attemptByRootNodeId.get(node.rootNodeId);
614 if (rootAttempt?.runId === item.runId) return rootAttempt;
615 nodeId = node.parentNodeId;
616 }
617 return undefined;
618 };
619
620 const foldedAnswerMessageIds = new Set(
621 input.visibleTurnItems.flatMap(({ item }) =>
622 item.type === "user_input_request" && item.questionAnswer
623 ? [`async-answer:${item.questionAnswer.requestId}`]
624 : [],
625 ),
626 );
627 for (const row of input.visibleTurnItems) {
628 const { item } = row;
629 if (turnItemIsWorkspacePreparation(item)) continue;
630 // Task progress belongs in the composer, not between conversation entries.
631 if (item.type === "todo_list" || item.type === "checkpoint") continue;
632 if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue;
633 const createdAt = projectedItemCreatedAt(row);
634 const attempt = resolveAttempt(item);
635 const attemptMetadata = attempt === undefined ? {} : { attempt };
636 if (item.type === "notification") {
637 entries.push({
638 id: item.id,
639 kind: "work",
640 createdAt,
641 entry: {
642 id: item.id,
643 createdAt,
644 runId: item.runId,
645 label: item.summary,
646 tone: "info",
647 itemType: item.type,
648 structuredPayload: item,
649 projectedItem: row,
650 },
651 ...attemptMetadata,
652 });
653 continue;
654 }
655 if (item.type === "user_message" || item.type === "assistant_message") {
656 const message: ChatMessage = {
657 id: item.messageId,
658 role: item.type === "user_message" ? "user" : "assistant",
659 text: item.text,
660 ...(item.type === "user_message" && item.context ? { context: item.context } : {}),
661 ...((item.attachments?.length ?? 0) > 0
662 ? {
663 attachments: (item.attachments ?? []).map((attachment) => {
664 const previewUrl = input.attachmentUrlById?.get(attachment.id);
665 return previewUrl ? { ...attachment, previewUrl } : attachment;
666 }),
667 }
668 : {}),
669 runId: item.runId,
670 streaming: item.type === "assistant_message" && item.streaming,
671 ...(item.type === "user_message"
672 ? {
673 createdBy: item.createdBy,
674 creationSource: item.creationSource,
675 ...(item.senderThreadId !== undefined ? { senderThreadId: item.senderThreadId } : {}),
676 ...(item.scheduledTaskId !== undefined
677 ? { scheduledTaskId: item.scheduledTaskId }
678 : {}),
679 }
680 : {}),
681 createdAt,
682 updatedAt: DateTime.formatIso(item.updatedAt),
683 ...(item.type === "user_message" ? { inputIntent: item.inputIntent } : {}),
684 };
685 committedMessageIds.add(message.id);
686 entries.push({
687 id: message.id,
688 kind: "message",
689 createdAt,
690 message,
691 projectedItem: row,
692 ...attemptMetadata,
693 });
694 continue;
695 }
696
697 if (item.type === "proposed_plan") {
698 const plan = planById.get(item.planId);
699 const proposedPlan = {
700 id: item.planId,
701 runId: item.runId,
702 planMarkdown: item.markdown,
703 status: plan?.kind === "proposed_plan" ? plan.status : ("active" as const),
704 createdAt,
705 updatedAt: DateTime.formatIso(item.updatedAt),
706 };
707 entries.push({
708 id: item.id,
709 kind: "proposed-plan",
710 createdAt,
711 proposedPlan,
712 ...attemptMetadata,
713 });
714 continue;
715 }
716
717 const htmlRender =
718 item.type === "dynamic_tool" && item.status === "completed"
719 ? htmlRenderFromToolItem(item)
720 : undefined;
721 if (htmlRender !== undefined) {
722 entries.push({
723 id: item.id,
724 kind: "html-render",
725 createdAt,
726 runId: item.runId,
727 htmlRender,
728 ...attemptMetadata,
729 });
730 continue;
731 }
732
733 const mcpApp =
734 item.type === "dynamic_tool" && item.status === "completed"
735 ? mcpAppFromToolItem(item)
736 : undefined;
737 if (mcpApp !== undefined) {
738 entries.push({
739 id: item.id,
740 kind: "mcp-app",
741 createdAt,
742 runId: item.runId,
743 sourceThreadId: row.sourceThreadId,
744 itemId: row.sourceItemId,
745 revision: turnItemDetailRevision(item),
746 mcpApp,
747 ...attemptMetadata,
748 });
749 continue;
750 }
751
752 if (STANDALONE_V2_ITEM_TYPES.has(item.type)) {
753 entries.push({
754 id: item.id,
755 kind: "event",
756 createdAt,
757 projectedItem: row,
758 ...attemptMetadata,
759 });
760 continue;
761 }
762
763 entries.push({
764 id: item.id,
765 kind: "work",
766 createdAt,
767 entry: projectedWorkEntry(row),
768 ...attemptMetadata,
769 });
770 }
771
772 const retainedMessageIds = new Set([...committedMessageIds, ...foldedAnswerMessageIds]);
773 for (const message of input.anchoredMessages ?? []) {
774 if (retainedMessageIds.has(message.id)) continue;
775 retainedMessageIds.add(message.id);
776 const entry: TimelineEntry = {
777 id: message.id,
778 kind: "message",
779 createdAt: message.createdAt,
780 message,
781 };
782 const insertionIndex = entries.findIndex(
783 (candidate) => candidate.createdAt > message.createdAt,
784 );
785 if (insertionIndex === -1) {
786 entries.push(entry);
787 } else {
788 entries.splice(insertionIndex, 0, entry);
789 }
790 }
791
792 for (const message of input.optimisticMessages) {
793 if (message.inputIntent !== "queued_turn" && !retainedMessageIds.has(message.id)) {
794 retainedMessageIds.add(message.id);
795 entries.push({
796 id: message.id,
797 kind: "message",
798 createdAt: message.createdAt,
799 message,
800 });
801 }
802 }
803
804 return entries;
805}
806
807type AttachmentResource = Extract<AssetResource, { readonly _tag: "attachment" }>;
808const EMPTY_IMAGE_RESOURCES = Object.freeze<ReadonlyArray<AttachmentResource>>([]);
809
810/** A mounted row requests its stored images. Local previews keep their existing URLs. */
811export function selectMessageImageResources(
812 attachments: ChatMessage["attachments"],
813): ReadonlyArray<AttachmentResource> {
814 const attachmentIds = new Set<string>();
815 for (const attachment of attachments ?? []) {
816 if (!isImageAttachment(attachment)) continue;
817 const previewUrl = attachment.previewUrl;
818 if (previewUrl?.startsWith("blob:") || previewUrl?.startsWith("data:")) continue;
819 attachmentIds.add(attachment.id);
820 }
821 return attachmentIds.size === 0
822 ? EMPTY_IMAGE_RESOURCES
823 : Array.from(attachmentIds, (attachmentId) => ({ _tag: "attachment", attachmentId }));
824}
825
826/** Handoffs need server URLs even while their message rows are unmounted. */
827export function selectHandoffImageResources(
828 messages: ReadonlyArray<Pick<ChatMessage, "id" | "role" | "attachments">> | undefined,
829 handoffs: Readonly<Record<string, ReadonlyArray<string>>>,
830): ReadonlyArray<AttachmentResource> {
831 if (Object.keys(handoffs).length === 0) return EMPTY_IMAGE_RESOURCES;
832 const attachmentIds = new Set<string>();
833 for (const message of messages ?? []) {
834 if (message.role !== "user" || !handoffs[message.id]?.length) continue;
835 for (const attachment of message.attachments ?? []) {
836 if (isImageAttachment(attachment)) attachmentIds.add(attachment.id);
837 }
838 }
839 return attachmentIds.size === 0
840 ? EMPTY_IMAGE_RESOURCES
841 : Array.from(attachmentIds, (attachmentId) => ({ _tag: "attachment", attachmentId }));
842}
843
844/** Own one mapper per preview stage. Immutable messages retain unchanged preview objects. */
845export function createMessageAttachmentPreviewProjector() {
846 const attachmentsBySource = new WeakMap<
847 ReadonlyArray<ChatAttachment>,
848 ReadonlyArray<ChatAttachment>
849 >();
850 const messagesBySource = new WeakMap<ChatMessage, ChatMessage>();
851 return (
852 message: ChatMessage,
853 previewUrlFor: (attachment: ChatAttachment) => string | undefined,
854 ): ChatMessage => {
855 const source = message.attachments;
856 if (!source || source.length === 0) return message;
857 const previous = attachmentsBySource.get(source) ?? source;
858 let changed: ChatAttachment[] | undefined;
859 let hasOverrides = false;
860 for (const [index, attachment] of source.entries()) {
861 const previewUrl = previewUrlFor(attachment);
862 const sourceUrl = "previewUrl" in attachment ? attachment.previewUrl : undefined;
863 const previousAttachment = previous[index]!;
864 const previousUrl =
865 "previewUrl" in previousAttachment ? previousAttachment.previewUrl : undefined;
866 const next =
867 !previewUrl || previewUrl === sourceUrl
868 ? attachment
869 : previewUrl === previousUrl
870 ? previousAttachment
871 : { ...attachment, previewUrl };
872 hasOverrides ||= next !== attachment;
873 if (next !== previousAttachment) {
874 changed ??= previous.slice();
875 changed[index] = next;
876 }
877 }
878 const attachments = hasOverrides ? (changed ?? previous) : source;
879 attachmentsBySource.set(source, attachments);
880 if (attachments === source) {
881 messagesBySource.delete(message);
882 return message;
883 }
884 const previousMessage = messagesBySource.get(message);
885 if (previousMessage?.attachments === attachments) return previousMessage;
886 const result = { ...message, attachments };
887 messagesBySource.set(message, result);
888 return result;
889 };
890}
891
892/** Text and update time do not change a streaming assistant message's row structure. */
893export function isStreamingMessageTextUpdate(previous: ChatMessage, next: ChatMessage): boolean {
894 if (
895 previous.role !== "assistant" ||
896 next.role !== "assistant" ||
897 !previous.streaming ||
898 !next.streaming
899 ) {
900 return false;
901 }
902 const { text: _previousText, updatedAt: _previousUpdatedAt, ...previousMetadata } = previous;
903 const { text: _nextText, updatedAt: _nextUpdatedAt, ...nextMetadata } = next;
904 return shallow(previousMetadata, nextMetadata);
905}
906
907/** Keep provenance and execution metadata in the rebuild boundary, including inspector data. */
908export function isStreamingTurnItemTextUpdate(
909 previous: OrchestrationV2ProjectedTurnItem,
910 next: OrchestrationV2ProjectedTurnItem,
911): boolean {
912 const { item: previousItem, ...previousSource } = previous;
913 const { item: nextItem, ...nextSource } = next;
914 if (
915 previousItem.type !== "assistant_message" ||
916 nextItem.type !== "assistant_message" ||
917 !previousItem.streaming ||
918 !nextItem.streaming ||
919 !shallow(previousSource, nextSource) ||
920 projectedItemCreatedAt(previous) !== projectedItemCreatedAt(next)
921 ) {
922 return false;
923 }
924 const { text: _previousText, updatedAt: _previousUpdatedAt, ...previousMetadata } = previousItem;
925 const { text: _nextText, updatedAt: _nextUpdatedAt, ...nextMetadata } = nextItem;
926 // Wire decoding can recreate timestamps and attachments on each update.
927 // Compare only the changed item's metadata, never the entire transcript.
928 return Equal.equals(previousMetadata, nextMetadata);
929}
930
931function reuseTimelineEntries(
932 input: TimelineEntriesInput,
933 previous: TimelineEntriesProjection,
934): TimelineEntry[] | null {
935 const before = previous.input;
936 if (
937 input.visibleTurnItems.length < before.visibleTurnItems.length ||
938 !shallow(input.optimisticMessages, before.optimisticMessages) ||
939 !shallow(input.anchoredMessages, before.anchoredMessages) ||
940 !shallow(input.attachmentUrlById, before.attachmentUrlById) ||
941 !shallow(input.attempts, before.attempts) ||
942 !shallow(input.nodes, before.nodes) ||
943 !shallow(input.plans, before.plans)
944 ) {
945 return null;
946 }
947 const appended = input.visibleTurnItems.length > before.visibleTurnItems.length;
948 // Anchored and optimistic messages need to be interleaved/deduplicated when
949 // committed items arrive. Keep the full projection for that transition.
950 if (
951 appended &&
952 (input.optimisticMessages.length > 0 || (input.anchoredMessages?.length ?? 0) > 0)
953 ) {
954 return null;
955 }
956 // Answer rows can replace a message already present in the retained prefix.
957 if (
958 appended &&
959 input.visibleTurnItems
960 .slice(before.visibleTurnItems.length)
961 .some(
962 ({ item }) =>
963 (item.type === "user_input_request" && item.questionAnswer !== undefined) ||
964 (item.type === "user_message" && item.messageId.startsWith("async-answer:")),
965 )
966 )
967 return null;
968 const replacements = new Map<
969 OrchestrationV2ProjectedTurnItem,
970 OrchestrationV2ProjectedTurnItem
971 >();
972 for (const [index, previousItem] of before.visibleTurnItems.entries()) {
973 const item = input.visibleTurnItems[index]!;
974 if (item === previousItem) continue;
975 if (!isStreamingTurnItemTextUpdate(previousItem, item)) return null;
976 replacements.set(previousItem, item);
977 }
978 if (replacements.size === 0 && !appended) return previous.entries;
979 const entries = previous.entries.map((entry): TimelineEntry => {
980 const row =
981 entry.kind === "message" && entry.projectedItem !== undefined
982 ? replacements.get(entry.projectedItem)
983 : undefined;
984 if (entry.kind !== "message" || row?.item.type !== "assistant_message") return entry;
985 return {
986 ...entry,
987 projectedItem: row,
988 message: {
989 ...entry.message,
990 text: row.item.text,
991 updatedAt: DateTime.formatIso(row.item.updatedAt),
992 },
993 };
994 });
995 if (appended) {
996 entries.push(
997 ...deriveTimelineEntriesFromVisibleTurnItems({
998 ...input,
999 visibleTurnItems: input.visibleTurnItems.slice(before.visibleTurnItems.length),
1000 }),
1001 );
1002 }
1003 return entries;
1004}
1005
1006/** Reuse immutable entries during streaming without reordering the canonical v2 sequence. */
1007export function deriveTimelineEntriesFromVisibleTurnItemsWithState(
1008 input: TimelineEntriesInput,
1009 previous: TimelineEntriesProjection | null = null,
1010): TimelineEntriesProjection {
1011 const reused = previous === null ? null : reuseTimelineEntries(input, previous);
1012 if (reused !== null) return { input, entries: reused };
1013 const entries = deriveTimelineEntriesFromVisibleTurnItems(input);
1014 if (previous === null || !shallow(input.attachmentUrlById, previous.input.attachmentUrlById)) {
1015 return { input, entries };
1016 }
1017 // Tool output and lifecycle changes rebuild grouping, but unchanged message
1018 // objects and previews still let memoized history rows stay mounted.
1019 const previousMessages = new Map(
1020 previous.entries.flatMap((entry) =>
1021 entry.kind === "message" ? [[entry.id, entry] as const] : [],
1022 ),
1023 );
1024 return {
1025 input,
1026 entries: entries.map((entry) => {
1027 if (entry.kind !== "message" || entry.projectedItem === undefined) return entry;
1028 const before = previousMessages.get(entry.id);
1029 if (before?.projectedItem !== entry.projectedItem) return entry;
1030 return before.attempt === entry.attempt ? before : { ...entry, message: before.message };
1031 }),
1032 };
1033}
1034
1035export function inferCheckpointTurnCountByRunId(
1036 summaries: ReadonlyArray<ThreadCheckpointSummary>,
1037): Record<string, number> {
1038 return Object.fromEntries(
1039 summaries.flatMap((summary) =>
1040 summary.runId === null ? [] : [[summary.runId, summary.checkpointTurnCount] as const],
1041 ),
1042 );
1043}
1044
1045export function deriveRevertTurnCountByUserMessageId(input: {
1046 readonly timelineEntries: ReadonlyArray<TimelineEntry>;
1047 readonly checkpoints: ReadonlyArray<ThreadCheckpointSummary>;
1048}): Map<ChatMessage["id"], number> {
1049 const readyCheckpointByRunId = new Map<RunId, ThreadCheckpointSummary>();
1050 for (const checkpoint of input.checkpoints) {
1051 if (checkpoint.status === "ready") {
1052 readyCheckpointByRunId.set(checkpoint.runId, checkpoint);
1053 }
1054 }
1055 const byUserMessageId = new Map<ChatMessage["id"], number>();
1056 for (const entry of input.timelineEntries) {
1057 if (entry.kind !== "message" || entry.message.role !== "user") continue;
1058 if (entry.message.inputIntent !== "turn_start" && entry.message.inputIntent !== "queued_turn") {
1059 continue;
1060 }
1061 if (entry.message.runId === null) continue;
1062 const checkpoint = readyCheckpointByRunId.get(entry.message.runId);
1063 if (checkpoint === undefined) continue;
1064 byUserMessageId.set(entry.message.id, Math.max(0, checkpoint.checkpointTurnCount - 1));
1065 }
1066 return byUserMessageId;
1067}
1068
1069export function derivePhase(runtime: ThreadRuntimeSummary | null): SessionPhase {
1070 if (runtime === null) return "disconnected";
1071 if (
1072 runtime.status === "preparing" ||
1073 runtime.status === "starting" ||
1074 runtime.status === "queued"
1075 )
1076 return "connecting";
1077 if (runtime.status === "running" || runtime.status === "waiting") return "running";
1078 return "ready";
1079}
1080
1081/**
1082 * Whether web and desktop offer Stop for the active thread. The server settles
1083 * a preparing or starting run on `run.interrupt` (Orchestrator.dispatchRunInterrupt),
1084 * so Stop must not wait for the phase to reach "running". A queued thread offers
1085 * Stop only while an earlier run is still interruptible; Stop targets that run.
1086 */
1087export function deriveCanInterruptRunningThread(
1088 hasActiveThread: boolean,
1089 runtime: ThreadRuntimeSummary | null,
1090): boolean {
1091 return (
1092 hasActiveThread &&
1093 (derivePhase(runtime) === "running" || threadRuntimeHasInterruptibleRun(runtime))
1094 );
1095}
1096
1097export type { TurnDiffSummary };