1import type {
2 ThreadPendingApproval,
3 ThreadPendingUserInput,
4 ThreadUserInputQuestion,
5} from "@t3tools/client-runtime/state/thread-requests";
6import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
7import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
8import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
9import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
10import {
11 turnItemDetailRevision,
12 turnItemHasDetail,
13 turnItemNeedsDetailFetch,
14} from "@t3tools/client-runtime/work-log/item-detail";
15import {
16 commandDisplayText,
17 commandProgramName,
18} from "@t3tools/client-runtime/work-log/command-label";
19import {
20 contextCompactionLabel,
21 liveThoughtLine,
22 toolItemForDisplay,
23 workEntryDisplayIndicatesToolFailure,
24 liveActivityToolStatus,
25 toolGroupAction,
26 resolveWorkEntryToolPresentation,
27 summarizeToolGroup,
28 toolGroupSummaryKind,
29 type ToolGroupSummaryKind,
30 type WorkLogPresentationEntry,
31 type WorkLogToolLifecycleStatus,
32} from "@t3tools/client-runtime/work-log/presentation";
33import {
34 resolveT3McpToolDefinition,
35 resolveT3McpToolPresentation,
36 type T3McpToolLogo,
37 type T3McpToolPresentation,
38} from "@t3tools/shared/t3McpToolPresentation";
39import type {
40 ChatAttachment,
41 MessageId,
42 OrchestrationV2Actor,
43 OrchestrationV2CreationSource,
44 OrchestrationV2ExecutionNode,
45 OrchestrationMessageContext,
46 OrchestrationV2ProjectedTurnItem,
47 OrchestrationV2RunAttempt,
48 OrchestrationV2RunStatus,
49 OrchestrationV2TurnItem,
50 OrchestrationV2UserMessageInputIntent,
51 RunAttemptId,
52 ScheduledTaskId,
53 TurnItemId,
54} from "@t3tools/contracts";
55import { RunId, ThreadId } from "@t3tools/contracts";
56import {
57 classifyToolActivity,
58 collectToolFilePaths,
59 dynamicToolTitle,
60 isJavaScriptReplTool,
61 formatReadToolLabel,
62 formatSearchToolLabel,
63} from "@t3tools/shared/toolActivity";
64import { formatDuration } from "@t3tools/shared/orchestrationTiming";
65import type { HtmlRenderReference } from "@t3tools/shared/htmlRender";
66import type { McpAppReference } from "@t3tools/shared/mcpApp";
67import {
68 compactDynamicToolOutput,
69 htmlRenderFromToolItem,
70 mcpAppFromToolItem,
71} from "@t3tools/shared/toolOutput";
72import * as DateTime from "effect/DateTime";
73
74export type PendingApproval = ThreadPendingApproval;
75export type PendingUserInput = ThreadPendingUserInput;
76
77export interface PendingUserInputDraftAnswer {
78 readonly selectedOptionValues?: ReadonlyArray<string>;
79 readonly customAnswer?: string;
80 readonly attachmentCount?: number;
81 readonly attachmentsBlocked?: boolean;
82}
83
84export interface ThreadFeedActivity {
85 readonly id: string;
86 readonly createdAt: string;
87 readonly runId: RunId | null;
88 readonly attemptId: RunAttemptId | null;
89 readonly summary: string;
90 readonly detail: string | null;
91 readonly canExpand: boolean;
92 /** Expanding fetches the withheld input and output with getTurnItem. */
93 readonly fetchesDetail: boolean;
94 readonly getFullDetail: () => string | null;
95 readonly getCopyText: () => string;
96 readonly icon:
97 | "agent"
98 | "alert"
99 | "browser"
100 | "computer"
101 | "code"
102 | "check"
103 | "command"
104 | "edit"
105 | "eye"
106 | "globe"
107 | "search"
108 | "hammer"
109 | "lock"
110 | "message"
111 | "warning"
112 | "wrench"
113 | "zap";
114 readonly logo: T3McpToolLogo | null;
115 readonly toolLike: boolean;
116 readonly prominent: boolean;
117 readonly status: "success" | "failure" | "neutral" | null;
118 readonly lifecycleStatus: WorkLogToolLifecycleStatus;
119 readonly workEntry: WorkLogPresentationEntry;
120 readonly groupedToolDetail?: boolean;
121 readonly live?: boolean;
122 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
123}
124
125export interface ThreadFeedMessage {
126 readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined;
127 readonly id: MessageId;
128 readonly role: "user" | "assistant";
129 readonly text: string;
130 readonly attachments: ReadonlyArray<ChatAttachment>;
131 readonly runId: RunId | null;
132 readonly streaming: boolean;
133 readonly inputIntent?: OrchestrationV2UserMessageInputIntent;
134 readonly createdBy?: OrchestrationV2Actor;
135 readonly creationSource?: OrchestrationV2CreationSource;
136 readonly scheduledTaskId?: ScheduledTaskId;
137 readonly senderThreadId?: ThreadId;
138 readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"];
139 readonly sourceThreadId: ThreadId;
140 readonly createdAt: string;
141 readonly updatedAt: string;
142 readonly projectedItem?: OrchestrationV2ProjectedTurnItem;
143}
144
145/** A message shown before the server has it: a pending creation or local feedback. */
146export interface LocalThreadMessage {
147 readonly id: MessageId;
148 readonly role: string;
149 readonly text: string;
150 readonly context?: OrchestrationMessageContext | undefined;
151 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
152 readonly streaming: boolean;
153 readonly createdAt: string;
154 readonly updatedAt: string;
155}
156
157type RawThreadFeedEntry =
158 | {
159 readonly type: "message";
160 readonly id: string;
161 readonly createdAt: string;
162 readonly message: ThreadFeedMessage;
163 }
164 | {
165 readonly type: "activity";
166 readonly id: string;
167 readonly createdAt: string;
168 readonly runId: RunId | null;
169 readonly activity: ThreadFeedActivity;
170 }
171 | {
172 /** A page a completed `html_render` call published, shown in place of its work row. */
173 readonly type: "html-render";
174 readonly id: string;
175 readonly createdAt: string;
176 readonly runId: RunId | null;
177 readonly render: HtmlRenderReference;
178 }
179 | {
180 /** An MCP App a completed tool call captured, hosted in place of its work row. */
181 readonly type: "mcp-app";
182 readonly id: string;
183 readonly createdAt: string;
184 readonly runId: RunId | null;
185 /** The thread and item that own the app; a fork's inherited app is its source's. */
186 readonly sourceThreadId: ThreadId;
187 readonly itemId: TurnItemId;
188 readonly revision: string;
189 readonly app: McpAppReference;
190 };
191
192export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean };
193
194type ThreadFeedEntryContent =
195 | Extract<RawThreadFeedEntry, { type: "message" | "html-render" | "mcp-app" }>
196 | {
197 readonly type: "activity-group";
198 readonly id: string;
199 readonly createdAt: string;
200 readonly runId: RunId | null;
201 readonly activities: ReadonlyArray<ThreadFeedActivity>;
202 }
203 | {
204 readonly type: "work-toggle";
205 readonly id: string;
206 readonly createdAt: string;
207 readonly runId: RunId | null;
208 readonly groupId: string;
209 readonly hiddenCount: number;
210 readonly expanded: boolean;
211 readonly summary: string;
212 readonly summaryKind: ToolGroupSummaryKind;
213 readonly toolSurface?: WorkLogPresentationEntry["toolSurface"];
214 readonly toolIcon?: WorkLogPresentationEntry["toolIcon"];
215 readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
216 readonly hasFailure: boolean;
217 readonly live: boolean;
218 readonly shimmer: boolean;
219 /** First sentence of the latest thought, shown above the live status line. */
220 readonly thought?: string;
221 }
222 | {
223 readonly type: "run-fold";
224 readonly id: string;
225 readonly createdAt: string;
226 readonly runId: RunId;
227 readonly label: string;
228 readonly expanded: boolean;
229 }
230 | {
231 readonly type: "thinking";
232 readonly id: string;
233 readonly createdAt: string;
234 readonly runId: RunId | null;
235 };
236
237export interface ThreadFeedLatestRun {
238 readonly runId: RunId;
239 readonly status: OrchestrationV2RunStatus;
240 readonly startedAt: string | null;
241 readonly completedAt: string | null;
242}
243
244export interface AgentSpawnSummary {
245 readonly title: string;
246 readonly status: string;
247 readonly tone: "working" | "completed" | "failed" | "stopped";
248 readonly members: ReadonlyArray<{
249 readonly title: string;
250 readonly status: string;
251 readonly tone: "working" | "completed" | "failed" | "stopped";
252 readonly detail: string | undefined;
253 readonly updatedAt: string;
254 }>;
255}
256
257function compactWorkEntryText(value: string): string {
258 return value.replace(/\s+/gu, " ").trim();
259}
260
261/** Expanded work rows keep their detail while compact rows show a stable one-line label. */
262export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = false): string {
263 if (expanded && entry.itemType === "reasoning")
264 return entry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought";
265 const presentation = resolveWorkEntryToolPresentation(entry);
266 if (presentation) return presentation.displayName;
267 if (entry.command?.trim()) return compactWorkEntryText(commandDisplayText(entry.command));
268 const action = toolGroupAction(entry);
269 const isToolRead = action === "read" && entry.itemType === "dynamic_tool";
270 if (action === "code-search" || action === "search") {
271 const toolData =
272 entry.toolData !== null &&
273 typeof entry.toolData === "object" &&
274 !Array.isArray(entry.toolData)
275 ? (entry.toolData as Record<string, unknown>)
276 : undefined;
277 // Adapters title file searches with their target; the item keeps only the pattern.
278 const searchLabel =
279 entry.itemType === "file_search" ? entry.label : formatSearchToolLabel(toolData);
280 if (searchLabel) return searchLabel;
281 }
282 if (isToolRead) {
283 const [firstPath] = entry.changedFiles ?? collectToolFilePaths(entry.toolData);
284 if (firstPath) {
285 return formatReadToolLabel(firstPath, Math.max(0, (entry.changedFiles?.length ?? 1) - 1));
286 }
287 if (!expanded) return "Read file";
288 }
289 const preview =
290 entry.command ??
291 (isToolRead || (!expanded && (action === "code-search" || action === "search"))
292 ? null
293 : entry.detail) ??
294 (entry.changedFiles?.length
295 ? entry.changedFiles.length === 1
296 ? entry.changedFiles[0]!
297 : `${entry.changedFiles[0]!} +${entry.changedFiles.length - 1} more`
298 : null);
299 if (expanded) return preview?.trim() || entry.label;
300 return preview ? compactWorkEntryText(preview) || entry.label : entry.label;
301}
302
303type ThreadFeedActivityGroup = Extract<ThreadFeedEntry, { readonly type: "activity-group" }>;
304
305// Immutable source rows let retained history keep its identities while the active item streams.
306const projectedEntriesCache = new WeakMap<
307 OrchestrationV2ProjectedTurnItem,
308 {
309 readonly attemptId: RunAttemptId | null;
310 readonly entry: RawThreadFeedEntry;
311 }
312>();
313const localMessageEntriesCache = new WeakMap<
314 LocalThreadMessage,
315 Extract<RawThreadFeedEntry, { readonly type: "message" }>
316>();
317const activityGroupsCache = new WeakMap<ThreadFeedActivity, ThreadFeedActivityGroup>();
318const failedActivityGroupsCache = new WeakMap<
319 ThreadFeedActivityGroup,
320 ReadonlyArray<ThreadFeedActivityGroup>
321>();
322const presentedActivityGroupsCache = new WeakMap<
323 ThreadFeedActivityGroup,
324 {
325 readonly activeRunId: RunId | null;
326 readonly isWorking: boolean;
327 readonly activeTail: boolean;
328 readonly rows: ReadonlyArray<ThreadFeedEntry>;
329 }
330>();
331const runFoldRowsCache = new WeakMap<
332 ThreadFeedEntry,
333 Extract<ThreadFeedEntry, { readonly type: "run-fold" }>
334>();
335let cachedThinkingRow: Extract<ThreadFeedEntry, { readonly type: "thinking" }> | null = null;
336
337export function isContextCompactionActivityGroup(entry: ThreadFeedActivityGroup): boolean {
338 return (
339 entry.activities.length === 1 && entry.activities[0]?.projectedItem.item.type === "compaction"
340 );
341}
342
343export function isContextHandoffActivityGroup(entry: ThreadFeedActivityGroup): boolean {
344 return (
345 entry.activities.length === 1 && entry.activities[0]?.projectedItem.item.type === "handoff"
346 );
347}
348
349export function isSecretRequestActivityGroup(entry: ThreadFeedActivityGroup): boolean {
350 return (
351 entry.activities.length === 1 &&
352 entry.activities[0]?.projectedItem.item.type === "secret_request"
353 );
354}
355
356function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean {
357 return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined);
358}
359
360function normalizeDraftAnswer(value: string | undefined): string | null {
361 if (typeof value !== "string") return null;
362 const trimmed = value.trim();
363 return trimmed.length > 0 ? trimmed : null;
364}
365
366function resolvePendingUserInputOptionValue(
367 question: ThreadUserInputQuestion,
368 value: string,
369): string | null {
370 if (question.options.some((option) => option.value === value)) {
371 return value;
372 }
373
374 const label = value.trim();
375 return label.length > 0 &&
376 question.options.some((option) => option.value === undefined && option.label.trim() === label)
377 ? label
378 : null;
379}
380
381function normalizeSelectedOptionValues(
382 question: ThreadUserInputQuestion,
383 value: ReadonlyArray<string> | undefined,
384): ReadonlyArray<string> {
385 if (!Array.isArray(value)) {
386 return [];
387 }
388
389 return Array.from(
390 new Set(
391 value
392 .map((entry) => resolvePendingUserInputOptionValue(question, entry))
393 .filter((entry): entry is string => entry !== null),
394 ),
395 );
396}
397
398function resolvePendingUserInputAnswer(
399 question: ThreadUserInputQuestion,
400 draft: PendingUserInputDraftAnswer | undefined,
401): string | ReadonlyArray<string> | null {
402 if (draft?.attachmentsBlocked) return null;
403 const customAnswer =
404 question.allowCustomAnswer === false
405 ? null
406 : question.initialAnswer !== undefined
407 ? (draft?.customAnswer ?? null)
408 : normalizeDraftAnswer(draft?.customAnswer);
409 if (customAnswer !== null && customAnswer.length > 0) {
410 return customAnswer;
411 }
412
413 const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
414 if (question.multiSelect) {
415 return selectedOptionValues.length > 0
416 ? selectedOptionValues
417 : (customAnswer ??
418 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null));
419 }
420 return (
421 selectedOptionValues[0] ??
422 customAnswer ??
423 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
424 );
425}
426
427function capitalizePhrase(value: string): string {
428 const trimmed = value.trim();
429 return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
430}
431
432function memoizeValue<T>(build: () => T): () => T {
433 let value: T;
434 let initialized = false;
435 return () => {
436 if (!initialized) {
437 value = build();
438 initialized = true;
439 }
440 return value;
441 };
442}
443
444function itemIsToolLike(item: OrchestrationV2TurnItem): boolean {
445 return (
446 item.type === "reasoning" ||
447 item.type === "command_execution" ||
448 item.type === "file_change" ||
449 item.type === "file_search" ||
450 item.type === "web_search" ||
451 item.type === "approval_request" ||
452 item.type === "user_input_request" ||
453 item.type === "dynamic_tool" ||
454 item.type === "subagent"
455 );
456}
457
458function itemIsProminent(item: OrchestrationV2TurnItem): boolean {
459 return (
460 item.type === "fork" ||
461 item.type === "thread_created" ||
462 item.type === "system_notice" ||
463 (item.type === "notification" && item.source.kind === "system") ||
464 // An answerable card: it must stand alone and never fold away with the run.
465 item.type === "secret_request"
466 );
467}
468
469function itemStatus(item: OrchestrationV2TurnItem): ThreadFeedActivity["status"] {
470 if (item.type === "notification") return item.outcome === "failed" ? "failure" : null;
471 if (item.type === "error") {
472 if (item.status === "failed")
473 return item.failure.class === "usage_limit" ? "neutral" : "failure";
474 return item.status === "completed" ? "success" : "neutral";
475 }
476 if (!itemIsToolLike(item)) return null;
477 if (item.status === "failed") return "failure";
478 return item.status === "completed" ? "success" : "neutral";
479}
480
481function itemLifecycleStatus(item: OrchestrationV2TurnItem): WorkLogToolLifecycleStatus {
482 switch (item.status) {
483 case "pending":
484 case "running":
485 case "waiting":
486 return "inProgress";
487 case "idle":
488 return "idle";
489 case "completed":
490 return "completed";
491 case "failed":
492 return "failed";
493 case "cancelled":
494 case "interrupted":
495 return "stopped";
496 }
497}
498
499function itemWorkLogTone(item: OrchestrationV2TurnItem): WorkLogPresentationEntry["tone"] {
500 if (item.type === "error") return "info";
501 if (item.type === "reasoning") return "thinking";
502 switch (item.type) {
503 case "command_execution":
504 case "file_change":
505 case "file_search":
506 case "web_search":
507 case "dynamic_tool":
508 case "subagent":
509 return "tool";
510 default:
511 return "info";
512 }
513}
514
515function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
516 if (item.type === "notification") {
517 const source = item.source;
518 switch (source.kind) {
519 case "subagent":
520 case "delegated_task":
521 return "hammer";
522 case "command":
523 return "command";
524 case "monitor":
525 return "eye";
526 case "background_task":
527 case "system":
528 return "zap";
529 default:
530 source satisfies never;
531 return "zap";
532 }
533 }
534 if (item.type === "dynamic_tool") {
535 const classified = classifyToolActivity({
536 itemType: "dynamic_tool_call",
537 data: { toolName: item.toolName ?? undefined, input: item.input },
538 });
539 if (classified === "read") {
540 return "eye";
541 }
542 if (classified === "search") return "search";
543 }
544 switch (item.type) {
545 case "reasoning":
546 return "agent";
547 case "command_execution":
548 return "command";
549 case "file_change":
550 return "edit";
551 case "file_search":
552 return "search";
553 case "web_search":
554 return "globe";
555 case "approval_request":
556 return item.requestKind === "permission" ? "lock" : "message";
557 case "user_input_request":
558 case "user_message":
559 case "assistant_message":
560 return "message";
561 case "dynamic_tool":
562 return isJavaScriptReplTool(item.toolName) ? "code" : "wrench";
563 case "subagent":
564 return "hammer";
565 case "run_interrupt_request":
566 case "run_interrupt_result":
567 case "system_notice":
568 return "warning";
569 case "error":
570 return item.failure.class === "usage_limit"
571 ? item.status === "completed"
572 ? "check"
573 : "warning"
574 : "alert";
575 case "checkpoint":
576 case "proposed_plan":
577 case "todo_list":
578 return "check";
579 case "compaction":
580 case "handoff":
581 case "fork":
582 case "thread_created":
583 return "zap";
584 case "secret_request":
585 return "lock";
586 }
587}
588
589function itemToolPresentation(item: OrchestrationV2TurnItem): T3McpToolPresentation | null {
590 if (item.type !== "dynamic_tool") {
591 return null;
592 }
593 return resolveT3McpToolPresentation(item.toolName) ?? resolveT3McpToolPresentation(item.title);
594}
595
596function itemSummary(
597 item: OrchestrationV2TurnItem,
598 toolPresentation: T3McpToolPresentation | null = null,
599): string {
600 if (item.type === "notification") return item.summary;
601 if (item.type === "system_notice") return item.message;
602 if (item.type === "compaction") return contextCompactionLabel(item);
603 const title =
604 (item.type === "dynamic_tool"
605 ? dynamicToolTitle(item.toolName, item.input, item.title)
606 : undefined) ?? item.title?.trim();
607 if (item.type === "subagent") return formatSubagentDisplayTitle(title || "Subagent");
608 if (title) return toolPresentation?.displayName ?? capitalizePhrase(title);
609 switch (item.type) {
610 case "reasoning":
611 return "Thinking";
612 case "command_execution":
613 return "Command";
614 case "file_change":
615 return item.changes !== undefined && item.changes.length > 1
616 ? `Changed ${item.changes.length} files`
617 : `Changed ${item.fileName}`;
618 case "file_search":
619 return item.title?.trim() || formatSearchToolLabel(item) || "Searched files";
620 case "web_search":
621 return "Searched the web";
622 case "approval_request":
623 return "Approval requested";
624 case "user_input_request":
625 return "Input requested";
626 case "checkpoint":
627 return "Checkpoint captured";
628 case "run_interrupt_request":
629 return "Interrupt requested";
630 case "run_interrupt_result":
631 return "Run interrupted";
632 case "error":
633 return item.failure.class === "usage_limit" ? "Usage limit reached" : "Provider error";
634 case "handoff":
635 return "Context handed off";
636 case "fork":
637 return "Thread forked";
638 case "thread_created":
639 return "Thread created";
640 case "secret_request":
641 return item.label;
642 case "dynamic_tool": {
643 const classified = classifyToolActivity({
644 itemType: "dynamic_tool_call",
645 data: { toolName: item.toolName ?? undefined, input: item.input },
646 });
647 if (classified === "read") {
648 const [path] = collectToolFilePaths({ input: item.input });
649 return formatReadToolLabel(path ?? "");
650 }
651 if (classified === "search") {
652 return formatSearchToolLabel({ input: item.input }) ?? item.toolName ?? "Tool call";
653 }
654 return toolPresentation?.displayName ?? item.toolName ?? "Tool call";
655 }
656 case "proposed_plan":
657 return "Proposed plan";
658 case "todo_list":
659 return "Plan updated";
660 case "user_message":
661 return "User message";
662 case "assistant_message":
663 return "Assistant message";
664 }
665}
666
667function itemPreview(item: OrchestrationV2TurnItem): string | null {
668 switch (item.type) {
669 case "reasoning":
670 return item.text || null;
671 case "command_execution":
672 return item.input || null;
673 case "file_change":
674 return item.fileName;
675 case "file_search":
676 return item.pattern ?? null;
677 case "web_search":
678 return item.patterns?.join(", ") ?? null;
679 case "approval_request":
680 return item.prompt ?? null;
681 case "user_input_request":
682 return item.questions.map((question) => question.question).join(" · ") || null;
683 case "checkpoint":
684 return item.files.length === 1
685 ? (item.files[0]?.path ?? null)
686 : `${item.files.length} changed files`;
687 case "run_interrupt_request":
688 case "run_interrupt_result":
689 case "system_notice":
690 return item.message || null;
691 case "error":
692 return item.failure.message;
693 case "compaction":
694 case "handoff":
695 return item.summary ?? null;
696 case "fork":
697 case "thread_created":
698 return item.targetThreadId;
699 case "secret_request":
700 return item.reason || null;
701 case "subagent":
702 return item.result ?? item.progress ?? item.prompt;
703 case "dynamic_tool":
704 return null;
705 case "notification":
706 return item.detail ?? null;
707 case "proposed_plan":
708 return item.markdown || null;
709 case "todo_list":
710 return `${item.steps.filter((step) => step.status === "completed").length}/${item.steps.length} completed`;
711 case "user_message":
712 case "assistant_message":
713 return item.text || null;
714 }
715}
716
717function toWorkLogEntry(
718 item: OrchestrationV2TurnItem,
719 createdAt: string,
720 summary: string,
721 detail: string | null,
722): WorkLogPresentationEntry {
723 const title = item.title?.trim() || null;
724 const common = {
725 ...extractToolActivityPresentation(item),
726 id: item.id,
727 createdAt,
728 label: summary,
729 tone: itemWorkLogTone(item),
730 itemType: item.type,
731 toolLifecycleStatus: itemLifecycleStatus(item),
732 structuredPayload: item,
733 ...(item.type === "user_input_request" && item.questionAnswer
734 ? { questionAnswer: item.questionAnswer }
735 : {}),
736 } as const;
737
738 switch (item.type) {
739 case "reasoning":
740 return { ...common, ...(item.text ? { detail: item.text } : {}) };
741 case "command_execution":
742 return {
743 ...common,
744 command: item.input,
745 rawCommand: item.input,
746 toolTitle: title ?? "Command",
747 toolData: item,
748 };
749 case "file_change":
750 return {
751 ...common,
752 changedFiles: [item.fileName],
753 toolTitle: title ?? "File change",
754 toolData: item,
755 };
756 case "file_search":
757 return {
758 ...common,
759 ...(item.pattern ? { detail: item.pattern } : {}),
760 toolTitle: title ?? "File search",
761 toolData: item,
762 };
763 case "web_search":
764 return {
765 ...common,
766 ...(item.patterns?.length ? { detail: item.patterns.join(", ") } : {}),
767 toolTitle: title ?? "Web search",
768 toolData: item,
769 };
770 case "checkpoint":
771 return { ...common, changedFiles: item.files.map((file) => file.path), toolData: item };
772 case "approval_request":
773 return {
774 ...common,
775 ...(item.prompt ? { detail: item.prompt } : {}),
776 requestKind: item.requestKind,
777 toolData: item,
778 };
779 case "dynamic_tool":
780 return {
781 ...common,
782 toolTitle: title ?? item.toolName ?? "Tool",
783 toolData: { input: item.input, output: item.output },
784 };
785 default:
786 return { ...common, ...(detail ? { detail } : {}), toolData: item };
787 }
788}
789
790/** Expanded detail for a row, from its wire item or the full item from getTurnItem. */
791export function formatItemFullDetail(
792 row: OrchestrationV2ProjectedTurnItem,
793 item: OrchestrationV2TurnItem,
794): string {
795 return JSON.stringify(
796 {
797 visibility: row.visibility,
798 sourceThreadId: row.sourceThreadId,
799 sourceItemId: row.sourceItemId,
800 item: toolItemForDisplay(item),
801 },
802 null,
803 2,
804 );
805}
806
807function toFeedActivity(
808 row: OrchestrationV2ProjectedTurnItem,
809 attemptId: RunAttemptId | null,
810): ThreadFeedActivity {
811 const item = row.item;
812 const toolPresentation = itemToolPresentation(item);
813 const summary = itemSummary(item, toolPresentation);
814 const detail = item.type === "notification" ? null : itemPreview(item);
815 const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt);
816 const workEntry = toWorkLogEntry(item, createdAt, summary, detail);
817 const readPaths =
818 item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read"
819 ? collectToolFilePaths(item)
820 : null;
821 const getFullDetail = memoizeValue(() =>
822 readPaths
823 ? readPaths.join("\n") || null
824 : item.type === "notification"
825 ? item.detail?.trim() || null
826 : formatItemFullDetail(row, item),
827 );
828 const getCopyText = memoizeValue(() =>
829 [summary, detail, getFullDetail()]
830 .filter(
831 (value, index, values): value is string =>
832 Boolean(value) && values.indexOf(value) === index,
833 )
834 .join("\n"),
835 );
836 return {
837 id: `${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`,
838 createdAt,
839 runId: item.runId,
840 attemptId,
841 summary,
842 detail,
843 canExpand:
844 !(item.type === "error" && item.status === "failed") &&
845 !(item.type === "notification" && item.source.kind === "system") &&
846 (readPaths
847 ? readPaths.length > 0 || turnItemNeedsDetailFetch(item)
848 : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined),
849 // Read rows show their paths, then the fetched file contents.
850 fetchesDetail: turnItemNeedsDetailFetch(item),
851 getFullDetail,
852 getCopyText,
853 icon: workEntry.toolSurface ?? itemIcon(item),
854 logo: toolPresentation?.logo ?? null,
855 toolLike: itemIsToolLike(item),
856 prominent: itemIsProminent(item) || (item.type === "error" && item.status === "failed"),
857 status:
858 item.type === "error" && item.failure.class === "usage_limit"
859 ? itemStatus(item)
860 : workEntryDisplayIndicatesToolFailure(workEntry)
861 ? "failure"
862 : itemStatus(item),
863 lifecycleStatus: itemLifecycleStatus(item),
864 workEntry,
865 projectedItem: row,
866 };
867}
868
869function singleToolCallLabel(activity: ThreadFeedActivity, expanded: boolean): string {
870 if (activity.workEntry.itemType === "reasoning")
871 return expanded
872 ? "Thought"
873 : compactWorkEntryText(activity.workEntry.detail ?? "") || "Thought";
874 const presentation = resolveWorkEntryToolPresentation(activity.workEntry, "completed");
875 if (presentation) return presentation.displayName;
876 const command = activity.workEntry.command?.trim();
877 return command || activity.summary;
878}
879
880function isEmptyMessage(entry: RawThreadFeedEntry): boolean {
881 return (
882 entry.type === "message" &&
883 entry.message.text.trim().length === 0 &&
884 entry.message.attachments.length === 0
885 );
886}
887
888function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] {
889 const childrenByRun = new Map<RunId, Set<string>>();
890 for (const entry of entries) {
891 if (entry.type !== "activity") continue;
892 const item = entry.activity.projectedItem.item;
893 if (item.type !== "subagent" || item.origin !== "app_owned" || item.runId === null) continue;
894 const children = childrenByRun.get(item.runId) ?? new Set<string>();
895 children.add(item.subagentId);
896 childrenByRun.set(item.runId, children);
897 }
898 const grouped: ThreadFeedEntry[] = [];
899 let firstActivityEntry: Extract<RawThreadFeedEntry, { readonly type: "activity" }> | null = null;
900 let openGroupActivities: ThreadFeedActivity[] = [];
901 const flushGroup = () => {
902 if (firstActivityEntry === null) return;
903 const cached = activityGroupsCache.get(firstActivityEntry.activity);
904 if (
905 cached &&
906 cached.activities.length === openGroupActivities.length &&
907 cached.activities.every((activity, index) => activity === openGroupActivities[index])
908 ) {
909 grouped.push(cached);
910 } else {
911 const group: ThreadFeedActivityGroup = {
912 type: "activity-group",
913 id: firstActivityEntry.id,
914 createdAt: firstActivityEntry.createdAt,
915 runId: firstActivityEntry.runId,
916 activities: openGroupActivities,
917 };
918 activityGroupsCache.set(firstActivityEntry.activity, group);
919 grouped.push(group);
920 }
921 firstActivityEntry = null;
922 openGroupActivities = [];
923 };
924
925 for (const entry of entries) {
926 // A successful delegation is already represented by its durable child card.
927 // Pending, failed and unmatched calls remain visible, even with identical prompts.
928 if (entry.type === "activity") {
929 const item = entry.activity.projectedItem.item;
930 if (
931 item.type === "dynamic_tool" &&
932 item.runId !== null &&
933 (item.status === "running" || item.status === "completed") &&
934 resolveT3McpToolDefinition(item.toolName)?.summaryAction === "delegate" &&
935 !workEntryDisplayIndicatesToolFailure(entry.activity.workEntry)
936 ) {
937 const output = compactDynamicToolOutput(item.output);
938 if (
939 !output?.isError &&
940 output?.taskId !== undefined &&
941 childrenByRun.get(item.runId)?.has(output.taskId)
942 )
943 continue;
944 }
945 }
946 // Skip empty messages so they don't break activity grouping.
947 if (isEmptyMessage(entry)) {
948 continue;
949 }
950
951 if (entry.type !== "activity") {
952 flushGroup();
953 grouped.push(entry);
954 continue;
955 }
956
957 const isStandaloneActivity =
958 entry.activity.projectedItem.item.type === "compaction" ||
959 entry.activity.projectedItem.item.type === "handoff" ||
960 entry.activity.projectedItem.item.type === "notification";
961 if (
962 isStandaloneActivity ||
963 entry.activity.prominent ||
964 (entry.activity.projectedItem.item.type === "subagent") !==
965 (firstActivityEntry?.activity.projectedItem.item.type === "subagent") ||
966 firstActivityEntry?.runId !== entry.runId ||
967 (entry.activity.projectedItem.item.type === "subagent" &&
968 firstActivityEntry?.activity.projectedItem.item.providerTurnId !==
969 entry.activity.projectedItem.item.providerTurnId) ||
970 (entry.activity.projectedItem.item.type !== "subagent" &&
971 firstActivityEntry?.activity.attemptId !== entry.activity.attemptId)
972 ) {
973 flushGroup();
974 }
975 firstActivityEntry ??= entry;
976 openGroupActivities.push(entry.activity);
977 if (isStandaloneActivity || entry.activity.prominent) {
978 flushGroup();
979 }
980 }
981 flushGroup();
982 return grouped;
983}
984
985function computeElapsedMs(startIso: string, endIso: string): number | null {
986 const start = Date.parse(startIso);
987 const end = Date.parse(endIso);
988 return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
989}
990
991function maxIsoTimestamp(left: string | null, right: string | null): string | null {
992 if (left === null) return right;
993 if (right === null) return left;
994 return Date.parse(right) > Date.parse(left) ? right : left;
995}
996
997function unsettledRunId(latestRun: ThreadFeedLatestRun | null): RunId | null {
998 return threadFeedRunIsUnsettled(latestRun) ? latestRun.runId : null;
999}
1000
1001export function threadFeedRunIsUnsettled(
1002 run: ThreadFeedLatestRun | null,
1003): run is ThreadFeedLatestRun {
1004 if (run === null || run.status === "queued") return false;
1005 return (
1006 run.completedAt === null ||
1007 run.status === "preparing" ||
1008 run.status === "starting" ||
1009 run.status === "running" ||
1010 run.status === "waiting"
1011 );
1012}
1013
1014export function threadFeedActivityIsVisible(
1015 activity: Pick<ThreadFeedActivity, "prominent" | "status" | "toolLike"> &
1016 Partial<Pick<ThreadFeedActivity, "lifecycleStatus">>,
1017): boolean {
1018 return (
1019 activity.prominent ||
1020 activity.lifecycleStatus === "stopped" ||
1021 activity.lifecycleStatus === "declined" ||
1022 activity.lifecycleStatus === "idle" ||
1023 !(activity.toolLike && activity.status === "neutral")
1024 );
1025}
1026
1027interface ThreadFeedRunFold {
1028 readonly runId: RunId;
1029 readonly createdAt: string;
1030 readonly hiddenEntryIds: ReadonlySet<string>;
1031 readonly label: string;
1032}
1033
1034export function failedFeedRunIds(
1035 feed: ReadonlyArray<ThreadFeedEntry>,
1036 latestRun: ThreadFeedLatestRun | null,
1037) {
1038 const failed = new Set<RunId>();
1039 if (latestRun?.status === "failed") failed.add(latestRun.runId);
1040 for (const entry of feed) {
1041 if (entry.type !== "activity-group") continue;
1042 for (const activity of entry.activities) {
1043 const item = activity.projectedItem.item;
1044 if (
1045 item.type === "error" &&
1046 item.status === "failed" &&
1047 item.parentItemId === null &&
1048 item.runId !== null
1049 )
1050 failed.add(item.runId);
1051 }
1052 }
1053 return failed;
1054}
1055
1056/**
1057 * A prompt without a run (a provider-native subagent, or a turn imported from
1058 * V1) folds its response like a run. `runlessWorkActive` keeps the latest
1059 * runless response open; V2 work must not reopen imported turns.
1060 */
1061function deriveThreadFeedRunFolds(
1062 feed: ReadonlyArray<ThreadFeedEntry>,
1063 latestRun: ThreadFeedLatestRun | null,
1064 runlessWorkActive: boolean,
1065 feedFailedRunIds: ReadonlySet<RunId>,
1066): ReadonlyMap<string, ThreadFeedRunFold> {
1067 const firstAssistantMessageIdByRun = new Map<RunId, string>();
1068 const terminalAssistantMessageIdByRun = new Map<RunId, string>();
1069 const interruptedRunIds = new Set<RunId>();
1070 const failedRunIds = new Set(feedFailedRunIds);
1071 const promptIndexByRunId = new Map<RunId, number>();
1072 for (const [index, entry] of feed.entries()) {
1073 // A steer joins a run that already started, maybe from a promptless wake.
1074 if (
1075 entry.type !== "message" ||
1076 entry.message.role !== "user" ||
1077 entry.message.inputIntent === "steer" ||
1078 entry.message.inputIntent === "promoted_queued_to_steer"
1079 ) {
1080 continue;
1081 }
1082 const runId = entry.message.runId;
1083 if (runId != null && !promptIndexByRunId.has(runId)) promptIndexByRunId.set(runId, index);
1084 }
1085 const groupsByRunId = new Map<
1086 RunId,
1087 { entries: ThreadFeedEntry[]; startBoundary: string | null }
1088 >();
1089 // Fold state is keyed by run, so each runless prompt lends its response a
1090 // stable key of its own. Decide per prompt, not per thread: a V1 thread's
1091 // first V2 run must not unfold every imported turn above it.
1092 let runlessKey: RunId | null = null;
1093 let responseKey: RunId | null = null;
1094 let pendingUserBoundary: string | null = null;
1095 for (const [index, entry] of feed.entries()) {
1096 if (entry.type === "message" && entry.message.role === "user") {
1097 pendingUserBoundary = entry.message.createdAt;
1098 runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null;
1099 responseKey = entry.message.runId ?? runlessKey;
1100 continue;
1101 }
1102 const ownRunId =
1103 entry.type === "message" && entry.message.role === "assistant"
1104 ? (entry.message.runId ?? runlessKey)
1105 : entry.type === "activity-group" ||
1106 entry.type === "html-render" ||
1107 entry.type === "mcp-app"
1108 ? (entry.runId ?? runlessKey)
1109 : null;
1110 if (!ownRunId) continue;
1111 // Resuming a subagent moves its item to the resuming run, but the item
1112 // keeps its place in the turn that started it. It folds with that turn,
1113 // so the later run's fold still opens under its own prompt.
1114 const runId =
1115 responseKey !== null && (promptIndexByRunId.get(ownRunId) ?? -1) > index
1116 ? responseKey
1117 : ownRunId;
1118 let group = groupsByRunId.get(runId);
1119 if (!group) {
1120 group = { entries: [], startBoundary: pendingUserBoundary };
1121 pendingUserBoundary = null;
1122 groupsByRunId.set(runId, group);
1123 }
1124 group.entries.push(entry);
1125 if (entry.type === "message") {
1126 if (!firstAssistantMessageIdByRun.has(runId)) {
1127 firstAssistantMessageIdByRun.set(runId, entry.id);
1128 }
1129 terminalAssistantMessageIdByRun.set(runId, entry.id);
1130 }
1131 if (entry.type !== "activity-group") continue;
1132 for (const activity of entry.activities) {
1133 const item = activity.projectedItem.item;
1134 if (item.type === "run_interrupt_result") interruptedRunIds.add(runId);
1135 if (
1136 runId === runlessKey &&
1137 item.type === "error" &&
1138 item.status === "failed" &&
1139 item.parentItemId === null
1140 ) {
1141 failedRunIds.add(runId);
1142 }
1143 }
1144 }
1145
1146 const activeRunId = unsettledRunId(latestRun);
1147 const foldsByAnchorId = new Map<string, ThreadFeedRunFold>();
1148 for (const [runId, group] of groupsByRunId) {
1149 if (
1150 runId === activeRunId ||
1151 (runlessWorkActive && runId === runlessKey) ||
1152 interruptedRunIds.has(runId) ||
1153 failedRunIds.has(runId) ||
1154 group.entries.some((entry) => entry.type === "message" && entry.message.streaming)
1155 ) {
1156 continue;
1157 }
1158 const firstAssistantId = firstAssistantMessageIdByRun.get(runId);
1159 const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId);
1160 const hiddenEntryIds = new Set(
1161 group.entries
1162 .filter(
1163 (entry) =>
1164 entry.id !== firstAssistantId &&
1165 entry.id !== terminalAssistantId &&
1166 entry.type !== "html-render" &&
1167 entry.type !== "mcp-app" &&
1168 !(
1169 entry.type === "activity-group" &&
1170 entry.activities.some(
1171 (activity) =>
1172 activity.prominent ||
1173 activity.projectedItem.item.type === "notification" ||
1174 activity.projectedItem.item.type === "handoff" ||
1175 // A child still working outlives its settled launching run;
1176 // its group stays visible while any member is live.
1177 isLiveSubagentTurnItem(activity.projectedItem.item),
1178 )
1179 ),
1180 )
1181 .map((entry) => entry.id),
1182 );
1183 const firstEntry = group.entries[0];
1184 const firstHiddenEntry = group.entries.find((entry) => hiddenEntryIds.has(entry.id));
1185 const lastEntry = group.entries.at(-1);
1186 if (!firstHiddenEntry || !firstEntry || !lastEntry) continue;
1187 const hidesNonCompactionWork = group.entries.some(
1188 (entry) =>
1189 hiddenEntryIds.has(entry.id) &&
1190 !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)),
1191 );
1192 if (!hidesNonCompactionWork) continue;
1193 const terminalEntry = terminalAssistantId
1194 ? group.entries.find((entry) => entry.id === terminalAssistantId)
1195 : null;
1196 const latestRunMatches = latestRun?.runId === runId;
1197 const lastEntryEnd =
1198 lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;
1199 const elapsedMs =
1200 latestRunMatches && latestRun.startedAt && latestRun.completedAt
1201 ? computeElapsedMs(latestRun.startedAt, latestRun.completedAt)
1202 : computeElapsedMs(
1203 group.startBoundary ?? firstEntry.createdAt,
1204 maxIsoTimestamp(
1205 terminalEntry?.type === "message" ? terminalEntry.message.updatedAt : null,
1206 lastEntryEnd,
1207 ) ?? lastEntryEnd,
1208 );
1209 const duration = elapsedMs === null ? null : formatDuration(elapsedMs);
1210 const interrupted =
1211 latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled");
1212 foldsByAnchorId.set(firstHiddenEntry.id, {
1213 runId,
1214 createdAt: firstHiddenEntry.createdAt,
1215 hiddenEntryIds,
1216 label: interrupted
1217 ? duration
1218 ? `You stopped after ${duration}`
1219 : "You stopped this response"
1220 : duration
1221 ? `Worked for ${duration}`
1222 : "Worked",
1223 });
1224 }
1225 return foldsByAnchorId;
1226}
1227
1228const supersededReasoningGroups = new WeakMap<ThreadFeedActivityGroup, ThreadFeedActivityGroup>();
1229const trailingReasoningGroups = new WeakMap<ThreadFeedActivityGroup, ThreadFeedActivityGroup>();
1230
1231/** A steer or subsequent activity ends thinking even if the provider omits its completion. */
1232function settleSupersededReasoning(
1233 entry: Exclude<ThreadFeedEntry, { readonly type: "run-fold" | "work-toggle" | "thinking" }>,
1234 tail: boolean,
1235) {
1236 if (entry.type !== "activity-group") return entry;
1237 const cache = tail ? trailingReasoningGroups : supersededReasoningGroups;
1238 const cached = cache.get(entry);
1239 if (cached) return cached;
1240 const activities = entry.activities.map((activity, index) =>
1241 activity.workEntry.itemType === "reasoning" &&
1242 activity.lifecycleStatus === "inProgress" &&
1243 (!tail || index < entry.activities.length - 1)
1244 ? {
1245 ...activity,
1246 lifecycleStatus: "completed" as const,
1247 status: "success" as const,
1248 workEntry: { ...activity.workEntry, toolLifecycleStatus: "completed" as const },
1249 }
1250 : activity,
1251 );
1252 const settled = activities.some((activity, index) => activity !== entry.activities[index])
1253 ? { ...entry, activities }
1254 : entry;
1255 cache.set(entry, settled);
1256 return settled;
1257}
1258
1259export function deriveThreadFeedPresentation(
1260 feed: ReadonlyArray<ThreadFeedEntry>,
1261 latestRun: ThreadFeedLatestRun | null,
1262 expandedRunIds: ReadonlySet<RunId>,
1263 expandedWorkGroupIds: ReadonlySet<string> = new Set(),
1264 activeWorkStartedAt: string | null = null,
1265 /** The live work is a provider-native subagent's runless root turn. */
1266 runlessWorkActive = false,
1267): ThreadFeedEntry[] {
1268 const retainedFeed = feed.filter(
1269 (entry) =>
1270 entry.type !== "run-fold" && entry.type !== "work-toggle" && entry.type !== "thinking",
1271 );
1272 const sourceFeed = retainedFeed.map((entry, index) =>
1273 settleSupersededReasoning(entry, index === retainedFeed.length - 1),
1274 );
1275 const failedRunIds = failedFeedRunIds(sourceFeed, latestRun);
1276 const activeTailGroup = sourceFeed.at(-1);
1277 const activeRunId = unsettledRunId(latestRun);
1278 const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing";
1279 const foldsByAnchorId = deriveThreadFeedRunFolds(
1280 sourceFeed,
1281 latestRun,
1282 isWorking && runlessWorkActive,
1283 failedRunIds,
1284 );
1285 const collapsedEntryIds = new Set<string>();
1286 for (const fold of foldsByAnchorId.values()) {
1287 if (!expandedRunIds.has(fold.runId)) {
1288 for (const entryId of fold.hiddenEntryIds) collapsedEntryIds.add(entryId);
1289 }
1290 }
1291 const result: ThreadFeedEntry[] = [];
1292 for (const entry of sourceFeed) {
1293 // A provider-native subagent works without a run: its null-run tail is
1294 // live only while that runless work is active.
1295 const isActiveTailGroup =
1296 isWorking &&
1297 (activeRunId !== null || runlessWorkActive) &&
1298 entry.type === "activity-group" &&
1299 activeTailGroup?.type === "activity-group" &&
1300 activeTailGroup.id === entry.id &&
1301 entry.runId === activeRunId;
1302 const fold = foldsByAnchorId.get(entry.id);
1303 if (fold) {
1304 const expanded = expandedRunIds.has(fold.runId);
1305 let row = runFoldRowsCache.get(entry);
1306 if (
1307 !row ||
1308 row.runId !== fold.runId ||
1309 row.createdAt !== fold.createdAt ||
1310 row.label !== fold.label ||
1311 row.expanded !== expanded
1312 ) {
1313 row = {
1314 type: "run-fold",
1315 id: `run-fold:${fold.runId}`,
1316 createdAt: fold.createdAt,
1317 runId: fold.runId,
1318 label: fold.label,
1319 expanded,
1320 };
1321 runFoldRowsCache.set(entry, row);
1322 }
1323 result.push(row);
1324 }
1325 if (!collapsedEntryIds.has(entry.id)) {
1326 if (
1327 entry.type === "activity-group" &&
1328 entry.runId !== null &&
1329 failedRunIds.has(entry.runId)
1330 ) {
1331 let rows = failedActivityGroupsCache.get(entry);
1332 if (!rows) {
1333 rows =
1334 entry.activities.length === 1
1335 ? [entry]
1336 : entry.activities.map((activity) => ({
1337 type: "activity-group" as const,
1338 id: activity.id,
1339 createdAt: activity.createdAt,
1340 runId: activity.runId,
1341 activities: [activity],
1342 }));
1343 failedActivityGroupsCache.set(entry, rows);
1344 }
1345 result.push(...rows);
1346 continue;
1347 }
1348 appendPresentedFeedEntry(
1349 result,
1350 entry,
1351 expandedWorkGroupIds,
1352 activeRunId,
1353 isWorking,
1354 isActiveTailGroup,
1355 );
1356 }
1357 }
1358 // Keep exactly one live slot while a run is working. When no tool row can
1359 // carry it yet (or the latest call failed), the slot reads "Thinking".
1360 if (
1361 isWorking &&
1362 activeWorkStartedAt !== null &&
1363 !result.some(
1364 (row) =>
1365 (row.type === "work-toggle" && row.shimmer) ||
1366 (row.type === "activity-group" &&
1367 isContextCompactionActivityGroup(row) &&
1368 row.runId === activeRunId &&
1369 row.activities[0]?.projectedItem.item.status === "running"),
1370 )
1371 ) {
1372 result.push(thinkingRow(activeWorkStartedAt, activeRunId));
1373 }
1374 return result.map((row, index) => {
1375 if (!isWorkLogFeedRow(row) || !isWorkLogFeedRow(result[index + 1])) return row;
1376 let continued = continuedWorkLogRows.get(row);
1377 if (!continued) {
1378 continued = { ...row, continuesWorkLog: true };
1379 continuedWorkLogRows.set(row, continued);
1380 }
1381 return continued;
1382 });
1383}
1384
1385const continuedWorkLogRows = new WeakMap<ThreadFeedEntry, ThreadFeedEntry>();
1386
1387function isWorkLogFeedRow(row: ThreadFeedEntry | undefined): boolean {
1388 return (
1389 row !== undefined &&
1390 (row.type === "work-toggle" ||
1391 row.type === "thinking" ||
1392 (row.type === "activity-group" &&
1393 !isContextCompactionActivityGroup(row) &&
1394 !isContextHandoffActivityGroup(row) &&
1395 row.activities.every(
1396 (activity) =>
1397 !activity.prominent &&
1398 activity.projectedItem.item.type !== "notification" &&
1399 activity.projectedItem.item.type !== "subagent",
1400 )))
1401 );
1402}
1403
1404/** Shared by the trailing live tool row and its Thinking fallback. */
1405export const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
1406
1407function thinkingRow(createdAt: string, runId: RunId | null) {
1408 if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.runId !== runId) {
1409 cachedThinkingRow = { type: "thinking", id: LIVE_ACTIVITY_ROW_ID, createdAt, runId };
1410 }
1411 return cachedThinkingRow;
1412}
1413
1414function appendPresentedFeedEntry(
1415 result: ThreadFeedEntry[],
1416 entry: Exclude<ThreadFeedEntry, { readonly type: "run-fold" | "work-toggle" | "thinking" }>,
1417 expandedWorkGroupIds: ReadonlySet<string>,
1418 activeRunId: RunId | null,
1419 isWorking: boolean,
1420 activeTail: boolean,
1421): void {
1422 if (entry.type !== "activity-group") {
1423 result.push(entry);
1424 return;
1425 }
1426 if (
1427 isContextCompactionActivityGroup(entry) ||
1428 isContextHandoffActivityGroup(entry) ||
1429 isUserInputActivityGroup(entry) ||
1430 entry.activities[0]?.projectedItem.item.type === "subagent"
1431 ) {
1432 result.push(entry);
1433 return;
1434 }
1435
1436 let cached = presentedActivityGroupsCache.get(entry);
1437 if (
1438 !cached ||
1439 cached.activeRunId !== activeRunId ||
1440 cached.isWorking !== isWorking ||
1441 cached.activeTail !== activeTail ||
1442 cached.rows.some(
1443 (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded,
1444 )
1445 ) {
1446 const rows: ThreadFeedEntry[] = [];
1447 appendActivityGroupRows(rows, entry, expandedWorkGroupIds, activeRunId, isWorking, activeTail);
1448 cached = { activeRunId, isWorking, activeTail, rows };
1449 presentedActivityGroupsCache.set(entry, cached);
1450 }
1451 for (const row of cached.rows) {
1452 result.push(row);
1453 }
1454}
1455
1456function appendActivityGroupRows(
1457 result: ThreadFeedEntry[],
1458 entry: ThreadFeedActivityGroup,
1459 expandedWorkGroupIds: ReadonlySet<string>,
1460 activeRunId: RunId | null,
1461 isWorking: boolean,
1462 activeTail: boolean,
1463): void {
1464 const groupAnchorIdByActivityId = new Map<string, string>();
1465 let groupAnchorId: string | null = null;
1466 for (const activity of entry.activities) {
1467 const item = activity.projectedItem.item;
1468 if (activity.prominent || (item.type === "error" && item.status === "failed")) {
1469 groupAnchorId = null;
1470 continue;
1471 }
1472 groupAnchorId ??= activity.id;
1473 groupAnchorIdByActivityId.set(activity.id, groupAnchorId);
1474 }
1475 const activities = entry.activities.filter(
1476 (activity) =>
1477 threadFeedActivityIsVisible(activity) ||
1478 (isWorking && activity.lifecycleStatus === "inProgress" && activity.runId === activeRunId),
1479 );
1480 if (activities.length === 0) {
1481 return;
1482 }
1483
1484 let groupableRun: ThreadFeedActivity[] = [];
1485 const flushGroupableRun = (isTrailingRun: boolean) => {
1486 if (groupableRun.length === 0) return;
1487 appendToolGroupRows(
1488 result,
1489 entry,
1490 groupableRun,
1491 `work-group:${groupAnchorIdByActivityId.get(groupableRun[0]!.id) ?? groupableRun[0]!.id}`,
1492 expandedWorkGroupIds,
1493 activeRunId,
1494 isWorking,
1495 activeTail && isTrailingRun,
1496 );
1497 groupableRun = [];
1498 };
1499 for (const activity of activities) {
1500 const item = activity.projectedItem.item;
1501 const severeProviderError = item.type === "error" && item.status === "failed";
1502 if (!activity.prominent && !severeProviderError && item.type !== "notification") {
1503 groupableRun.push(activity);
1504 continue;
1505 }
1506 flushGroupableRun(false);
1507 result.push({
1508 type: "activity-group",
1509 id: activity.id,
1510 createdAt: activity.createdAt,
1511 runId: activity.runId,
1512 activities: [activity],
1513 });
1514 }
1515 flushGroupableRun(true);
1516}
1517
1518function appendToolGroupRows(
1519 result: ThreadFeedEntry[],
1520 sourceGroup: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>,
1521 activities: ReadonlyArray<ThreadFeedActivity>,
1522 groupId: string,
1523 expandedWorkGroupIds: ReadonlySet<string>,
1524 activeRunId: RunId | null,
1525 isWorking: boolean,
1526 activeTail: boolean,
1527): void {
1528 const expanded = expandedWorkGroupIds.has(groupId);
1529 const latestActiveActivity = activities.findLast(
1530 (activity) =>
1531 isWorking && activity.lifecycleStatus === "inProgress" && activity.runId === activeRunId,
1532 );
1533 const active = latestActiveActivity !== undefined;
1534 const live = activeTail || active;
1535 const latestActivity = latestActiveActivity ?? activities.at(-1)!;
1536 // A successful trailing call remains the live slot until the next activity.
1537 // Failed/stopped calls hand that slot to the Thinking row.
1538 const shimmer = activeTail && (active || latestActivity.status === "success");
1539 const singleActivity = activities.length === 1 ? latestActivity : null;
1540 const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry));
1541 const latestThought =
1542 live && !expanded
1543 ? activities.findLast(
1544 (activity) =>
1545 activity.workEntry.itemType === "reasoning" &&
1546 (activity.workEntry.detail?.trim() ?? "") !== "",
1547 )?.workEntry.detail
1548 : undefined;
1549 const thought = latestThought ? liveThoughtLine(latestThought) : "";
1550 const summary = live
1551 ? (expanded || thought) && latestActivity.workEntry.itemType === "reasoning"
1552 ? latestActivity.lifecycleStatus === "inProgress"
1553 ? "Thinking"
1554 : "Thought"
1555 : liveToolActivitySummary(latestActivity, live)
1556 : singleActivity !== null &&
1557 singleActivity.toolLike &&
1558 toolGroupAction(singleActivity.workEntry) !== "edit"
1559 ? singleToolCallLabel(singleActivity, expanded)
1560 : singleActivity !== null && !singleActivity.toolLike
1561 ? singleActivity.workEntry.label
1562 : groupSummary.summary;
1563 const primarySourceActivity = activities.find(
1564 (activity) => activity.workEntry.toolSource !== undefined,
1565 );
1566 const primarySourceKey = primarySourceActivity?.workEntry.toolSource?.key;
1567 const primarySourceIcon = primarySourceKey
1568 ? (activities.find(
1569 (activity) =>
1570 activity.workEntry.toolSource?.key === primarySourceKey &&
1571 activity.workEntry.toolIcon !== undefined,
1572 )?.workEntry.toolIcon ?? primarySourceActivity?.workEntry.toolSource?.icon)
1573 : undefined;
1574 const groupToolSurface =
1575 primarySourceActivity?.workEntry.toolSurface ??
1576 latestActivity.workEntry.toolSurface ??
1577 activities.findLast((activity) => activity.workEntry.toolSurface !== undefined)?.workEntry
1578 .toolSurface;
1579 const groupToolIcon =
1580 primarySourceIcon ??
1581 latestActivity.workEntry.toolIcon ??
1582 activities.findLast((activity) => activity.workEntry.toolIcon !== undefined)?.workEntry
1583 .toolIcon;
1584 const summaryToolIcon = live
1585 ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon
1586 : singleActivity !== null &&
1587 singleActivity.toolLike &&
1588 toolGroupAction(singleActivity.workEntry) !== "edit"
1589 ? resolveWorkEntryToolPresentation(singleActivity.workEntry, "completed")?.icon
1590 : undefined;
1591 result.push({
1592 type: "work-toggle",
1593 id: shimmer ? LIVE_ACTIVITY_ROW_ID : `${live ? "work-live" : "work-toggle"}:${groupId}`,
1594 createdAt: sourceGroup.createdAt,
1595 runId: sourceGroup.runId,
1596 groupId,
1597 hiddenCount: activities.length,
1598 expanded,
1599 summary,
1600 summaryKind: toolGroupSummaryKind(
1601 (live ? [latestActivity] : activities).map((activity) => activity.workEntry),
1602 ),
1603 ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}),
1604 ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}),
1605 ...(summaryToolIcon ? { summaryToolIcon } : {}),
1606 ...(thought ? { thought } : {}),
1607 hasFailure: (() => {
1608 const lastToolLike = activities.findLast((activity) => activity.toolLike);
1609 return (
1610 lastToolLike !== undefined && workEntryDisplayIndicatesToolFailure(lastToolLike.workEntry)
1611 );
1612 })(),
1613 live,
1614 shimmer,
1615 });
1616 if (!expanded) return;
1617 result.push({
1618 type: "activity-group",
1619 id: `work-details:${groupId}`,
1620 createdAt: activities[0]!.createdAt,
1621 runId: activities[0]!.runId,
1622 activities: activities.map((activity) => ({
1623 ...activity,
1624 groupedToolDetail: true,
1625 live:
1626 isWorking &&
1627 activity.id === latestActivity.id &&
1628 activity.lifecycleStatus === "inProgress" &&
1629 activity.runId === activeRunId,
1630 })),
1631 });
1632}
1633
1634function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boolean): string {
1635 const status = liveActivityToolStatus(activity.lifecycleStatus, presentTense);
1636 if (activity.workEntry.itemType === "reasoning") {
1637 return (
1638 activity.workEntry.detail?.trim().replace(/\s+/g, " ") ||
1639 (status === "inProgress" ? "Thinking" : "Thought")
1640 );
1641 }
1642 const presentation = resolveWorkEntryToolPresentation({
1643 ...activity.workEntry,
1644 toolLifecycleStatus: status,
1645 });
1646 if (presentation) return presentation.displayName;
1647 const command = activity.workEntry.command?.trim();
1648 if (command) {
1649 const program = commandProgramName(command);
1650 const verb =
1651 status === "inProgress"
1652 ? "Running"
1653 : status === "failed"
1654 ? "Failed"
1655 : status === "declined"
1656 ? "Declined"
1657 : status === "stopped"
1658 ? "Stopped"
1659 : "Ran";
1660 return `${verb} ${program ?? "command"}`;
1661 }
1662 return activity.detail ?? activity.summary;
1663}
1664
1665export function setPendingUserInputCustomAnswer(
1666 question: ThreadUserInputQuestion,
1667 draft: PendingUserInputDraftAnswer | undefined,
1668 customAnswer: string,
1669): PendingUserInputDraftAnswer {
1670 if (question.allowCustomAnswer === false) {
1671 return draft ?? {};
1672 }
1673
1674 const selectedOptionValues =
1675 question.initialAnswer !== undefined || customAnswer.trim().length > 0
1676 ? undefined
1677 : normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
1678 return {
1679 customAnswer,
1680 ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}),
1681 };
1682}
1683
1684export function isPendingUserInputOptionSelected(
1685 question: ThreadUserInputQuestion,
1686 draft: PendingUserInputDraftAnswer | undefined,
1687 optionValue: string,
1688): boolean {
1689 if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) {
1690 return false;
1691 }
1692
1693 const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue);
1694 return (
1695 resolvedOptionValue !== null &&
1696 normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes(
1697 resolvedOptionValue,
1698 )
1699 );
1700}
1701
1702export function togglePendingUserInputOptionSelection(
1703 question: ThreadUserInputQuestion,
1704 draft: PendingUserInputDraftAnswer | undefined,
1705 optionValue: string,
1706): PendingUserInputDraftAnswer {
1707 const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue);
1708 if (resolvedOptionValue === null) {
1709 return draft ?? {};
1710 }
1711
1712 if (question.multiSelect) {
1713 const selectedOptionValues = normalizeSelectedOptionValues(
1714 question,
1715 draft?.selectedOptionValues,
1716 );
1717 const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue)
1718 ? selectedOptionValues.filter((value) => value !== resolvedOptionValue)
1719 : [...selectedOptionValues, resolvedOptionValue];
1720
1721 return {
1722 customAnswer: "",
1723 ...(nextSelectedOptionValues.length > 0
1724 ? { selectedOptionValues: nextSelectedOptionValues }
1725 : {}),
1726 };
1727 }
1728
1729 return {
1730 customAnswer: "",
1731 selectedOptionValues: [resolvedOptionValue],
1732 };
1733}
1734
1735export function buildPendingUserInputAnswers(
1736 questions: ReadonlyArray<ThreadUserInputQuestion>,
1737 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
1738): Record<string, string | ReadonlyArray<string>> | null {
1739 const answers: Record<string, string | ReadonlyArray<string>> = {};
1740
1741 for (const question of questions) {
1742 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
1743 if (answer === null) {
1744 return null;
1745 }
1746 answers[question.id] = answer;
1747 }
1748
1749 return answers;
1750}
1751
1752/**
1753 * Projects the server-authored visible sequence into mobile row presentation.
1754 * It deliberately preserves the incoming order and never rebuilds chat from
1755 * separate message, plan, or work-entry collections.
1756 */
1757export function buildThreadFeed(
1758 visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
1759 options?: {
1760 readonly anchoredMessages?: ReadonlyArray<LocalThreadMessage>;
1761 readonly attempts?: ReadonlyArray<OrchestrationV2RunAttempt>;
1762 readonly nodes?: ReadonlyArray<OrchestrationV2ExecutionNode>;
1763 },
1764): ThreadFeedEntry[] {
1765 const entries: RawThreadFeedEntry[] = [];
1766 const attemptByRootNodeId = new Map(
1767 (options?.attempts ?? []).map((attempt) => [attempt.rootNodeId, attempt] as const),
1768 );
1769 const nodeById = new Map((options?.nodes ?? []).map((node) => [node.id, node] as const));
1770 const resolveAttemptId = (item: OrchestrationV2TurnItem): RunAttemptId | null => {
1771 if (item.nodeId === null || item.runId === null) return null;
1772 let nodeId: OrchestrationV2ExecutionNode["id"] | null = item.nodeId;
1773 const visited = new Set<OrchestrationV2ExecutionNode["id"]>();
1774 while (nodeId !== null && !visited.has(nodeId)) {
1775 visited.add(nodeId);
1776 const directAttempt = attemptByRootNodeId.get(nodeId);
1777 if (directAttempt?.runId === item.runId) return directAttempt.id;
1778 const node = nodeById.get(nodeId);
1779 if (node === undefined) return null;
1780 const rootAttempt = attemptByRootNodeId.get(node.rootNodeId);
1781 if (rootAttempt?.runId === item.runId) return rootAttempt.id;
1782 nodeId = node.parentNodeId;
1783 }
1784 return null;
1785 };
1786 const foldedAnswerMessageIds = new Set(
1787 visibleTurnItems.flatMap(({ item }) =>
1788 item.type === "user_input_request" && item.questionAnswer
1789 ? [`async-answer:${item.questionAnswer.requestId}`]
1790 : [],
1791 ),
1792 );
1793 for (const row of visibleTurnItems) {
1794 const item = row.item;
1795 if (turnItemIsWorkspacePreparation(item)) continue;
1796 if (item.type === "todo_list" || item.type === "checkpoint") continue;
1797 if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue;
1798 // Match the web timeline: only the terminal interrupt result is useful to
1799 // users; the preceding request is transient bookkeeping.
1800 if (item.type === "run_interrupt_request") {
1801 continue;
1802 }
1803 const attemptId = resolveAttemptId(item);
1804 const cached = projectedEntriesCache.get(row);
1805 if (cached?.attemptId === attemptId) {
1806 entries.push(cached.entry);
1807 continue;
1808 }
1809 const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt);
1810 // A running or failed render stays an ordinary work row.
1811 const render =
1812 item.type === "dynamic_tool" && item.status === "completed"
1813 ? htmlRenderFromToolItem(item)
1814 : undefined;
1815 if (render) {
1816 const entry: RawThreadFeedEntry = {
1817 type: "html-render",
1818 id: `html-render:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`,
1819 createdAt,
1820 runId: item.runId,
1821 render,
1822 };
1823 projectedEntriesCache.set(row, { attemptId, entry });
1824 entries.push(entry);
1825 continue;
1826 }
1827 const app =
1828 item.type === "dynamic_tool" && item.status === "completed"
1829 ? mcpAppFromToolItem(item)
1830 : undefined;
1831 if (app) {
1832 const entry: RawThreadFeedEntry = {
1833 type: "mcp-app",
1834 id: `mcp-app:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`,
1835 createdAt,
1836 runId: item.runId,
1837 sourceThreadId: row.sourceThreadId,
1838 itemId: row.sourceItemId,
1839 revision: turnItemDetailRevision(item),
1840 app,
1841 };
1842 projectedEntriesCache.set(row, { attemptId, entry });
1843 entries.push(entry);
1844 continue;
1845 }
1846 if (item.type === "user_message" || item.type === "assistant_message") {
1847 const updatedAt = DateTime.formatIso(item.updatedAt);
1848 const entry: RawThreadFeedEntry = {
1849 type: "message",
1850 id: item.messageId,
1851 createdAt,
1852 message: {
1853 id: item.messageId,
1854 role: item.type === "user_message" ? "user" : "assistant",
1855 text: item.text,
1856 ...(item.type === "user_message" && item.context ? { context: item.context } : {}),
1857 attachments: item.attachments ?? [],
1858 runId: item.runId,
1859 streaming: item.type === "assistant_message" && item.streaming,
1860 ...(item.type === "user_message"
1861 ? {
1862 inputIntent: item.inputIntent,
1863 createdBy: item.createdBy,
1864 creationSource: item.creationSource,
1865 ...(item.scheduledTaskId ? { scheduledTaskId: item.scheduledTaskId } : {}),
1866 ...(item.senderThreadId ? { senderThreadId: item.senderThreadId } : {}),
1867 }
1868 : {}),
1869 visibility: row.visibility,
1870 sourceThreadId: row.sourceThreadId,
1871 createdAt,
1872 updatedAt,
1873 projectedItem: row,
1874 },
1875 };
1876 projectedEntriesCache.set(row, { attemptId, entry });
1877 entries.push(entry);
1878 continue;
1879 }
1880 const activity = toFeedActivity(row, attemptId);
1881 const entry: RawThreadFeedEntry = {
1882 type: "activity",
1883 id: activity.id,
1884 createdAt,
1885 runId: item.runId,
1886 activity,
1887 };
1888 projectedEntriesCache.set(row, { attemptId, entry });
1889 entries.push(entry);
1890 }
1891 const retainedMessageIds = new Set([
1892 ...foldedAnswerMessageIds,
1893 ...entries.flatMap((entry) => (entry.type === "message" ? [entry.id] : [])),
1894 ]);
1895 const appendLocalMessage = (message: LocalThreadMessage): RawThreadFeedEntry => {
1896 const cached = localMessageEntriesCache.get(message);
1897 if (cached) return cached;
1898 const entry: Extract<RawThreadFeedEntry, { readonly type: "message" }> = {
1899 type: "message",
1900 id: message.id,
1901 createdAt: message.createdAt,
1902 message: {
1903 id: message.id,
1904 role: message.role === "assistant" ? "assistant" : "user",
1905 text: message.text,
1906 ...(message.context ? { context: message.context } : {}),
1907 attachments: message.attachments ?? [],
1908 runId: null,
1909 streaming: message.streaming,
1910 visibility: "local",
1911 sourceThreadId: ThreadId.make("local-feedback"),
1912 createdAt: message.createdAt,
1913 updatedAt: message.updatedAt,
1914 },
1915 };
1916 localMessageEntriesCache.set(message, entry);
1917 return entry;
1918 };
1919 for (const message of options?.anchoredMessages ?? []) {
1920 if (retainedMessageIds.has(message.id)) continue;
1921 retainedMessageIds.add(message.id);
1922 const entry = appendLocalMessage(message);
1923 const insertionIndex = entries.findIndex(
1924 (candidate) => candidate.createdAt > message.createdAt,
1925 );
1926 if (insertionIndex === -1) entries.push(entry);
1927 else entries.splice(insertionIndex, 0, entry);
1928 }
1929 return groupAdjacentActivities(entries);
1930}