1import type { V2ItemSupport } from "@t3tools/client-runtime/state/item-support";
2import { toolItemForDisplay } from "@t3tools/client-runtime/work-log/presentation";
3import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
4import type { ThreadId } from "@t3tools/contracts";
5import { formatDuration } from "@t3tools/shared/orchestrationTiming";
6import * as DateTime from "effect/DateTime";
7
8import type { ThreadFeedActivity } from "./threadActivity";
9
10export interface ThreadActivityInspectorField {
11 readonly label: string;
12 readonly value: string;
13}
14
15export interface ThreadActivityInspectorBlock {
16 readonly label: string;
17 readonly value: string;
18 readonly monospaced: boolean;
19}
20
21export interface ThreadActivityFileLink {
22 readonly label: string;
23 readonly path: string;
24 readonly line?: number;
25}
26
27export interface ThreadActivityWebLink {
28 readonly label: string;
29 readonly url: string;
30}
31
32export interface ThreadActivityInspectorModel {
33 readonly fields: ReadonlyArray<ThreadActivityInspectorField>;
34 readonly blocks: ReadonlyArray<ThreadActivityInspectorBlock>;
35 readonly fileLinks: ReadonlyArray<ThreadActivityFileLink>;
36 readonly webLinks: ReadonlyArray<ThreadActivityWebLink>;
37 readonly canRollback: boolean;
38 readonly rollbackTarget: {
39 readonly threadId: ThreadId;
40 readonly checkpointId: string;
41 readonly scopeId: string;
42 } | null;
43 readonly structuredDetails: string;
44}
45
46function formatStructured(value: unknown): string {
47 if (typeof value === "string") return value;
48 try {
49 return JSON.stringify(value, null, 2) ?? String(value);
50 } catch {
51 return String(value);
52 }
53}
54
55function durationLabel(
56 startedAt: DateTime.Utc | null,
57 completedAt: DateTime.Utc | null,
58): string | null {
59 if (startedAt === null) return null;
60 const start = DateTime.toEpochMillis(startedAt);
61 const end = completedAt === null ? Date.now() : DateTime.toEpochMillis(completedAt);
62 return formatDuration(Math.max(0, end - start));
63}
64
65function addBlock(
66 blocks: ThreadActivityInspectorBlock[],
67 label: string,
68 value: unknown,
69 monospaced = true,
70): void {
71 if (value === undefined || value === null || value === "") return;
72 blocks.push({ label, value: formatStructured(value), monospaced });
73}
74
75export function buildThreadActivityInspector(
76 activity: Pick<ThreadFeedActivity, "projectedItem">,
77 support: V2ItemSupport,
78 currentThreadId: ThreadId,
79): ThreadActivityInspectorModel {
80 const row = activity.projectedItem;
81 const item = row.item;
82 const fields: ThreadActivityInspectorField[] = [
83 { label: "Item", value: item.type.replaceAll("_", " ") },
84 { label: "Status", value: item.status.replaceAll("_", " ") },
85 ];
86 const duration = durationLabel(item.startedAt, item.completedAt);
87 if (duration) fields.push({ label: "Duration", value: duration });
88 if (row.visibility !== "local") fields.push({ label: "Visibility", value: row.visibility });
89 if (support.run) fields.push({ label: "Run", value: support.run.status });
90
91 const latestAttempt = support.attempts.at(-1);
92 if (latestAttempt) {
93 fields.push({
94 label: "Attempt",
95 value: `${latestAttempt.attemptOrdinal} · ${latestAttempt.status} · ${latestAttempt.reason.replaceAll("_", " ")}`,
96 });
97 }
98 if (support.node) {
99 fields.push({
100 label: "Node",
101 value: `${support.node.kind.replaceAll("_", " ")} · ${support.node.status}`,
102 });
103 }
104 if (support.providerThread) {
105 fields.push({
106 label: "Provider thread",
107 value: `${support.providerThread.providerInstanceId} · ${support.providerThread.status}`,
108 });
109 }
110 if (support.providerTurn) {
111 fields.push({ label: "Provider turn", value: support.providerTurn.status });
112 }
113 if (support.providerSession) {
114 fields.push({
115 label: "Session",
116 value: `${support.providerSession.status} · ${support.providerSession.model ?? "default model"}`,
117 });
118 fields.push({ label: "Working directory", value: support.providerSession.cwd });
119 }
120 if (support.runtimeRequest) {
121 fields.push({
122 label: "Request",
123 value: `${support.runtimeRequest.status} · ${support.runtimeRequest.responseCapability.type.replaceAll("_", " ")}`,
124 });
125 }
126
127 const blocks: ThreadActivityInspectorBlock[] = [];
128 const fileLinks: ThreadActivityFileLink[] = [];
129 const webLinks: ThreadActivityWebLink[] = [];
130
131 if (support.attempts.length > 1) {
132 addBlock(
133 blocks,
134 "Attempt history",
135 support.attempts
136 .map(
137 (attempt) =>
138 `Attempt ${attempt.attemptOrdinal} · ${attempt.status} · ${attempt.reason.replaceAll("_", " ")}`,
139 )
140 .join("\n"),
141 );
142 }
143
144 switch (item.type) {
145 case "reasoning":
146 addBlock(blocks, "Reasoning", item.text, false);
147 break;
148 case "command_execution":
149 addBlock(blocks, "Command", item.input);
150 if (item.exitCode !== undefined) {
151 addBlock(blocks, "Exit", `Process exited with code ${item.exitCode}`);
152 }
153 break;
154 case "file_change":
155 for (const change of item.changes ?? [{ path: item.fileName, operation: "modify" }]) {
156 fileLinks.push({
157 label: `${change.operation} ${change.oldPath ? `${change.oldPath} → ` : ""}${change.path}`,
158 path: change.path,
159 });
160 }
161 if (item.additions !== undefined || item.deletions !== undefined) {
162 fields.push({
163 label: "Changes",
164 value: `+${formatDiffCount(item.additions ?? 0)} −${formatDiffCount(item.deletions ?? 0)}`,
165 });
166 }
167 break;
168 case "file_search":
169 addBlock(blocks, "Query", item.pattern);
170 for (const result of item.results ?? []) {
171 fileLinks.push({
172 label: result.preview ? `${result.fileName} — ${result.preview}` : result.fileName,
173 path: result.fileName,
174 ...(result.line === undefined ? {} : { line: result.line }),
175 });
176 }
177 break;
178 case "web_search":
179 addBlock(blocks, "Queries", item.patterns?.join("\n"));
180 for (const result of item.results ?? []) {
181 if (result.url) {
182 webLinks.push({
183 label: result.title ?? result.url,
184 url: result.url,
185 });
186 }
187 if (result.snippet)
188 addBlock(blocks, result.title ?? "Search result", result.snippet, false);
189 }
190 break;
191 case "dynamic_tool":
192 addBlock(blocks, "Input", item.input);
193 break;
194 case "approval_request":
195 addBlock(blocks, "Prompt", item.prompt, false);
196 break;
197 case "user_input_request":
198 addBlock(
199 blocks,
200 "Questions",
201 item.questions.map((question) => question.question).join("\n"),
202 false,
203 );
204 break;
205 case "checkpoint":
206 addBlock(
207 blocks,
208 "Files",
209 item.files
210 .map(
211 (file) =>
212 `${file.path} +${formatDiffCount(file.additions)} −${formatDiffCount(file.deletions)} ${file.kind}`,
213 )
214 .join("\n"),
215 );
216 break;
217 case "subagent":
218 addBlock(blocks, "Prompt", item.prompt, false);
219 addBlock(blocks, "Progress", support.subagent?.progress ?? item.progress, false);
220 addBlock(blocks, "Result", support.subagent?.result ?? item.result, false);
221 if (support.subagent) {
222 fields.push({
223 label: "Delegated task",
224 value: `${support.subagent.origin.replaceAll("_", " ")} · ${support.subagent.status}`,
225 });
226 }
227 break;
228 case "handoff":
229 addBlock(blocks, "Summary", item.summary, false);
230 fields.push({
231 label: "Handoff",
232 value: `${item.strategy.replaceAll("_", " ")} · ${support.contextHandoff?.status ?? item.status}`,
233 });
234 if (support.contextTransfer) {
235 fields.push({
236 label: "Transfer",
237 value: `${support.contextTransfer.type.replaceAll("_", " ")} · ${support.contextTransfer.status}`,
238 });
239 if (support.contextTransfer.resolution) {
240 fields.push({
241 label: "Context",
242 value: support.contextTransfer.resolution.strategy.replaceAll("_", " "),
243 });
244 }
245 }
246 break;
247 case "error":
248 addBlock(blocks, "Error", item.failure.message, false);
249 if (item.failure.code) fields.push({ label: "Code", value: item.failure.code });
250 if (item.failure.retryable !== null) {
251 fields.push({ label: "Retryable", value: item.failure.retryable ? "yes" : "no" });
252 }
253 break;
254 case "proposed_plan":
255 addBlock(blocks, "Plan", item.markdown, false);
256 break;
257 case "todo_list":
258 addBlock(
259 blocks,
260 "Tasks",
261 item.steps
262 .map((step) => `${step.status === "completed" ? "✓" : "○"} ${step.text}`)
263 .join("\n"),
264 false,
265 );
266 addBlock(blocks, "Explanation", item.explanation, false);
267 break;
268 case "compaction":
269 addBlock(blocks, "Summary", item.summary, false);
270 if (item.beforeTokenCount !== undefined || item.afterTokenCount !== undefined) {
271 fields.push({
272 label: "Context tokens",
273 value: `${item.beforeTokenCount ?? "?"} → ${item.afterTokenCount ?? "?"}`,
274 });
275 }
276 break;
277 case "run_interrupt_request":
278 case "run_interrupt_result":
279 addBlock(blocks, "Message", item.message, false);
280 break;
281 case "fork":
282 case "thread_created":
283 case "user_message":
284 case "assistant_message":
285 break;
286 }
287
288 const checkpoint =
289 item.type === "checkpoint" && row.sourceThreadId === currentThreadId
290 ? support.checkpoint
291 : null;
292 return {
293 fields,
294 blocks,
295 fileLinks,
296 webLinks,
297 canRollback: checkpoint?.status === "ready",
298 rollbackTarget:
299 checkpoint?.status === "ready"
300 ? {
301 threadId: row.sourceThreadId,
302 checkpointId: checkpoint.id,
303 scopeId: checkpoint.scopeId,
304 }
305 : null,
306 structuredDetails: formatStructured({
307 visibility: row.visibility,
308 sourceThreadId: row.sourceThreadId,
309 sourceItemId: row.sourceItemId,
310 item: toolItemForDisplay(item),
311 }),
312 };
313}