@tt-a1i/openpi 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +13 -11
  2. package/SETUP.md +2 -2
  3. package/THIRD_PARTY_NOTICES.md +242 -0
  4. package/extensions/ai-providers/README.md +12 -6
  5. package/extensions/ai-providers/cursor/connect-frame-reader.ts +76 -0
  6. package/extensions/ai-providers/cursor/input-images.ts +2 -3
  7. package/extensions/ai-providers/cursor/proto.ts +218 -11
  8. package/extensions/ai-providers/cursor/protobuf.ts +12 -2
  9. package/extensions/ai-providers/cursor/provider.ts +280 -37
  10. package/extensions/ai-providers/cursor/tool-bridge.ts +68 -0
  11. package/extensions/ai-providers/index.ts +3 -3
  12. package/extensions/file-mutation-display/index.ts +13 -9
  13. package/extensions/shared/agent-transcript.ts +3 -2
  14. package/extensions/shared/child-session.ts +14 -0
  15. package/extensions/subagents/index.ts +20 -3
  16. package/extensions/subagents/src/agent-types.ts +5 -17
  17. package/extensions/subagents/src/backends/pi.ts +70 -59
  18. package/extensions/subagents/src/backends/tool-preview.ts +29 -0
  19. package/extensions/subagents/src/manager.ts +2 -71
  20. package/extensions/subagents/src/prompt.ts +2 -2
  21. package/extensions/subagents/src/runtime.ts +10 -3
  22. package/extensions/web/index.ts +69 -5
  23. package/extensions/workflows/artifacts.ts +362 -23
  24. package/extensions/workflows/dashboard.ts +141 -21
  25. package/extensions/workflows/index.ts +62 -20
  26. package/extensions/workflows/progress-projection.ts +7 -1
  27. package/extensions/workflows/runner.ts +5 -162
  28. package/extensions/workflows/sandbox.ts +4 -0
  29. package/package.json +28 -4
  30. package/skills/subagents/REFERENCE.md +6 -7
  31. package/skills/subagents/SKILL.md +1 -1
  32. package/skills/workflows/REFERENCE.md +3 -1
  33. package/web/dist/app.js +87 -0
  34. package/web/dist/favicon.svg +9 -0
  35. package/web/dist/index.html +15 -0
  36. package/web/dist/styles.css +3 -0
  37. package/web/host/web-host.ts +6 -9
  38. package/web/ui/index.html +3 -131
  39. package/web/ui/public/favicon.svg +9 -0
  40. package/web/ui/src/app/App.tsx +134 -0
  41. package/web/ui/src/app/providers.tsx +38 -0
  42. package/web/ui/src/components/Markdown.tsx +58 -0
  43. package/web/ui/src/components/OpenPiLogo.tsx +41 -0
  44. package/web/ui/src/features/activity/ActivityBar.tsx +120 -0
  45. package/web/ui/src/features/composer/Composer.tsx +237 -0
  46. package/web/ui/src/features/sessions/SessionSidebar.tsx +418 -0
  47. package/web/ui/src/features/transcript/Transcript.tsx +860 -0
  48. package/web/ui/src/i18n.ts +159 -0
  49. package/web/ui/src/lib/format.ts +57 -0
  50. package/web/ui/src/main.tsx +16 -0
  51. package/web/ui/src/protocol/client.ts +199 -0
  52. package/web/ui/src/protocol/event-stream.ts +88 -0
  53. package/web/ui/src/store/web-store.ts +926 -0
  54. package/web/ui/src/styles.css +420 -0
  55. package/web/ui/tsconfig.json +12 -0
  56. package/web/ui/vite-env.d.ts +1 -0
  57. package/web/vite.config.mjs +21 -1
  58. package/web/host/static-assets.ts +0 -4
  59. package/web/ui/app.js +0 -1700
  60. package/web/ui/styles.css +0 -680
@@ -0,0 +1,860 @@
1
+ import {
2
+ Bot,
3
+ Check,
4
+ Clipboard,
5
+ FilePenLine,
6
+ FileText,
7
+ Folder,
8
+ Globe,
9
+ Lightbulb,
10
+ Pencil,
11
+ Search,
12
+ Terminal,
13
+ Workflow,
14
+ Wrench,
15
+ X,
16
+ } from "lucide-react";
17
+ import {
18
+ Fragment,
19
+ type ReactNode,
20
+ useEffect,
21
+ useLayoutEffect,
22
+ useMemo,
23
+ useRef,
24
+ useState,
25
+ } from "react";
26
+ import { useTranslation } from "react-i18next";
27
+ import type {
28
+ WebLiveMessage,
29
+ WebMessagePart,
30
+ WebSnapshot,
31
+ } from "../../../../protocol/types.ts";
32
+ import { Markdown } from "../../components/Markdown.tsx";
33
+ import {
34
+ compactSummary,
35
+ formatElapsedMs,
36
+ formatTurnTime,
37
+ turnTitle,
38
+ } from "../../lib/format.ts";
39
+ import type { LiveEntry } from "../../store/web-store.ts";
40
+
41
+ type PersistedEntry = NonNullable<
42
+ WebSnapshot["selectedSession"]
43
+ >["entries"][number];
44
+ interface DisplayEntry {
45
+ key: string;
46
+ timestamp?: string;
47
+ message: WebLiveMessage;
48
+ }
49
+
50
+ interface TranscriptProps {
51
+ snapshot: WebSnapshot;
52
+ liveMessages: LiveEntry[];
53
+ liveRunning: boolean;
54
+ livePhase: "idle" | "preparing" | "running";
55
+ liveRetry: { attempt: number; maxAttempts: number } | null;
56
+ thinkingStarts: Record<string, number>;
57
+ thinkingDurations: Record<string, number>;
58
+ scrollToBottom: number;
59
+ onResend: (content: string) => Promise<boolean>;
60
+ }
61
+
62
+ type Status = "running" | "done" | "error" | "warn" | "unknown";
63
+ interface RenderRow {
64
+ key: string;
65
+ content: ReactNode;
66
+ groupable?: boolean;
67
+ error?: boolean;
68
+ icon?: ReactNode;
69
+ }
70
+
71
+ function record(value: unknown): Record<string, unknown> {
72
+ return value && typeof value === "object"
73
+ ? (value as Record<string, unknown>)
74
+ : {};
75
+ }
76
+
77
+ function parseArguments(raw: string) {
78
+ try {
79
+ return record(JSON.parse(raw));
80
+ } catch {
81
+ return {};
82
+ }
83
+ }
84
+
85
+ function canonicalStatus(value: unknown): Status {
86
+ if (value === "running") return "running";
87
+ if (value === "done" || value === "completed") return "done";
88
+ if (
89
+ ["error", "failed", "aborted", "killed", "timed_out"].includes(
90
+ String(value),
91
+ )
92
+ ) {
93
+ return "error";
94
+ }
95
+ if (value === "uncertain") return "warn";
96
+ return "unknown";
97
+ }
98
+
99
+ function resultStatus(message?: WebLiveMessage): Status {
100
+ if (!message) return "running";
101
+ if (message.isError) return "error";
102
+ const status = canonicalStatus(record(message.details).status);
103
+ if (status !== "unknown") return status;
104
+ return message.isError === false ? "done" : "unknown";
105
+ }
106
+
107
+ function StatusMark({ status }: { status: Status }) {
108
+ if (status === "running") {
109
+ return (
110
+ <span className="status-mark running" role="img" aria-label="running">
111
+ <i />
112
+ </span>
113
+ );
114
+ }
115
+ if (status === "done")
116
+ return <Check className="status-mark done" aria-label="completed" />;
117
+ if (status === "error")
118
+ return <X className="status-mark error" aria-label="failed" />;
119
+ if (status === "warn")
120
+ return (
121
+ <span className="status-mark warn" role="img" aria-label="uncertain">
122
+ ?
123
+ </span>
124
+ );
125
+ return null;
126
+ }
127
+
128
+ function iconForTool(name: string) {
129
+ const lowered = name.toLowerCase();
130
+ if (lowered === "bash") return <Terminal />;
131
+ if (lowered === "read") return <FileText />;
132
+ if (lowered === "write" || lowered === "edit") return <FilePenLine />;
133
+ if (lowered === "grep") return <Search />;
134
+ if (lowered === "glob" || lowered === "ls") return <Folder />;
135
+ if (lowered === "webfetch" || lowered === "websearch") return <Globe />;
136
+ return <Wrench />;
137
+ }
138
+
139
+ function toolSummary(name: string, args: Record<string, unknown>) {
140
+ const value =
141
+ name === "bash"
142
+ ? args.command
143
+ : ["read", "write", "edit", "ls"].includes(name)
144
+ ? args.path
145
+ : ["grep", "glob"].includes(name)
146
+ ? args.pattern
147
+ : name === "webfetch"
148
+ ? args.url
149
+ : name === "websearch"
150
+ ? args.query
151
+ : "";
152
+ return typeof value === "string"
153
+ ? compactSummary(value.split("\n").find(Boolean), 90)
154
+ : "";
155
+ }
156
+
157
+ function EvidenceDetails({
158
+ body,
159
+ icon,
160
+ name,
161
+ status,
162
+ summary,
163
+ thinking = false,
164
+ }: {
165
+ body: string;
166
+ icon: ReactNode;
167
+ name: string;
168
+ status: Status;
169
+ summary?: string;
170
+ thinking?: boolean;
171
+ }) {
172
+ return (
173
+ <details
174
+ className={`message-details tool-line ${status === "error" ? "error" : ""} ${thinking ? "thinking-line" : ""}`}
175
+ >
176
+ <summary>
177
+ <span className="details-mark" aria-hidden="true" />
178
+ <span className="tool-icon" aria-hidden="true">
179
+ {icon}
180
+ </span>
181
+ <span className="details-title">
182
+ <span className="tool-name">{name}</span>
183
+ {summary && <span className="tool-summary">{summary}</span>}
184
+ </span>
185
+ <StatusMark status={status} />
186
+ </summary>
187
+ <pre className="details-body tool-evidence">{body}</pre>
188
+ </details>
189
+ );
190
+ }
191
+
192
+ function ActivityCard({
193
+ body,
194
+ family,
195
+ meta,
196
+ status,
197
+ title,
198
+ }: {
199
+ body: string;
200
+ family: "subagent" | "workflow";
201
+ meta?: string;
202
+ status: Status;
203
+ title: string;
204
+ }) {
205
+ return (
206
+ <details className={`message-details activity-card ${family}`}>
207
+ <summary>
208
+ <span className="activity-icon" aria-hidden="true">
209
+ {family === "subagent" ? <Bot /> : <Workflow />}
210
+ </span>
211
+ <span className="activity-main">
212
+ <span className="activity-title">{title}</span>
213
+ {meta && <span className="activity-meta">{meta}</span>}
214
+ </span>
215
+ <StatusMark status={status} />
216
+ <span className="details-mark" aria-hidden="true" />
217
+ </summary>
218
+ <pre className="details-body tool-evidence">{body}</pre>
219
+ </details>
220
+ );
221
+ }
222
+
223
+ function familyCard(
224
+ part: Extract<WebMessagePart, { type: "toolCall" }>,
225
+ result?: WebLiveMessage,
226
+ ) {
227
+ const name = part.name || "";
228
+ const args = parseArguments(part.arguments);
229
+ const details = record(result?.details);
230
+ const status = resultStatus(result);
231
+ if (name === "subagent_spawn") {
232
+ const meta = [args.agent_type, args.model, args.working_dir]
233
+ .filter(Boolean)
234
+ .join(" · ");
235
+ return (
236
+ <ActivityCard
237
+ family="subagent"
238
+ title={`Spawn Subagent · ${String(details.title || args.name || "subagent")}`}
239
+ meta={meta || String(details.cwd || "")}
240
+ body={result?.content || String(args.prompt || part.arguments)}
241
+ status={status}
242
+ />
243
+ );
244
+ }
245
+ if (name.startsWith("subagent")) {
246
+ const action = name.replaceAll("_", " ").replace(/^subagent /u, "");
247
+ return (
248
+ <ActivityCard
249
+ family="subagent"
250
+ title={`${action[0]?.toUpperCase() || ""}${action.slice(1)} Subagent`}
251
+ meta={String(args.id || "")}
252
+ body={result?.content || part.arguments}
253
+ status={status}
254
+ />
255
+ );
256
+ }
257
+ if (name === "workflow") {
258
+ const script =
259
+ typeof args.script === "string" ? args.script : part.arguments;
260
+ const workflowName = String(
261
+ details.name ||
262
+ script.match(/\bname:\s*["'`]([^"'`]+)["'`]/u)?.[1] ||
263
+ "unnamed",
264
+ );
265
+ const agents = record(details.agents);
266
+ const meta = [
267
+ details.runId,
268
+ details.status,
269
+ agents.total
270
+ ? `${Number(agents.total) - Number(agents.running || 0)}/${agents.total} agents`
271
+ : "",
272
+ ]
273
+ .filter(Boolean)
274
+ .join(" · ");
275
+ return (
276
+ <ActivityCard
277
+ family="workflow"
278
+ title={`Workflow · ${workflowName}`}
279
+ meta={meta}
280
+ body={result?.content || script}
281
+ status={status}
282
+ />
283
+ );
284
+ }
285
+ if (name.startsWith("workflow")) {
286
+ return (
287
+ <ActivityCard
288
+ family="workflow"
289
+ title={name.replaceAll("_", " ")}
290
+ meta={String(args.runId || "")}
291
+ body={result?.content || part.arguments}
292
+ status={status}
293
+ />
294
+ );
295
+ }
296
+ return null;
297
+ }
298
+
299
+ function useElapsed(start: number | undefined, active: boolean) {
300
+ const [now, setNow] = useState(Date.now());
301
+ useEffect(() => {
302
+ if (!active) return;
303
+ const interval = window.setInterval(() => setNow(Date.now()), 1_000);
304
+ return () => window.clearInterval(interval);
305
+ }, [active]);
306
+ return start ? formatElapsedMs(start, active ? now : Date.now()) : "";
307
+ }
308
+
309
+ function ThinkingEvidence({
310
+ body,
311
+ start,
312
+ duration,
313
+ active,
314
+ }: {
315
+ body: string;
316
+ start?: number;
317
+ duration?: number;
318
+ active: boolean;
319
+ }) {
320
+ const { t } = useTranslation();
321
+ const elapsed = useElapsed(start, active);
322
+ const settled = duration ? formatElapsedMs(0, duration) : elapsed;
323
+ return (
324
+ <EvidenceDetails
325
+ body={body}
326
+ icon={<Lightbulb />}
327
+ name={active ? t("thinkingActive") : t("thinkingDone")}
328
+ status={active ? "running" : "done"}
329
+ summary={settled ? `· ${settled}` : undefined}
330
+ thinking
331
+ />
332
+ );
333
+ }
334
+
335
+ function MessageActions({
336
+ content,
337
+ editable,
338
+ timestamp,
339
+ onResend,
340
+ }: {
341
+ content: string;
342
+ editable: boolean;
343
+ timestamp?: string;
344
+ onResend: (value: string) => Promise<boolean>;
345
+ }) {
346
+ const { t } = useTranslation();
347
+ const [copied, setCopied] = useState(false);
348
+ const [editing, setEditing] = useState(false);
349
+ const [draft, setDraft] = useState(content);
350
+ const editInput = useRef<HTMLTextAreaElement>(null);
351
+ useEffect(() => {
352
+ if (editing) editInput.current?.focus();
353
+ }, [editing]);
354
+ if (editing) {
355
+ return (
356
+ <div className="message-editor">
357
+ <textarea
358
+ ref={editInput}
359
+ value={draft}
360
+ onChange={(event) => setDraft(event.target.value)}
361
+ onKeyDown={(event) => {
362
+ if (event.key === "Escape") setEditing(false);
363
+ if (
364
+ event.key === "Enter" &&
365
+ !event.shiftKey &&
366
+ !event.nativeEvent.isComposing
367
+ ) {
368
+ event.preventDefault();
369
+ void onResend(draft.trim()).then(
370
+ (sent) => sent && setEditing(false),
371
+ );
372
+ }
373
+ }}
374
+ />
375
+ <div className="message-edit-actions">
376
+ <button type="button" onClick={() => setEditing(false)}>
377
+ {t("cancel")}
378
+ </button>
379
+ <button
380
+ type="button"
381
+ className="confirm"
382
+ onClick={() =>
383
+ void onResend(draft.trim()).then(
384
+ (sent) => sent && setEditing(false),
385
+ )
386
+ }
387
+ >
388
+ {t("confirmEdit")}
389
+ </button>
390
+ </div>
391
+ </div>
392
+ );
393
+ }
394
+ const time = formatTurnTime(timestamp);
395
+ return (
396
+ <div className="message-actions">
397
+ {time && <time dateTime={timestamp}>{time}</time>}
398
+ {editable && (
399
+ <button
400
+ type="button"
401
+ aria-label={t("editMessage")}
402
+ title={t("editMessage")}
403
+ onClick={() => setEditing(true)}
404
+ >
405
+ <Pencil />
406
+ </button>
407
+ )}
408
+ <button
409
+ type="button"
410
+ aria-label={copied ? t("copiedMessage") : t("copyMessage")}
411
+ title={copied ? t("copiedMessage") : t("copyMessage")}
412
+ onClick={() => {
413
+ void navigator.clipboard.writeText(content).then(() => {
414
+ setCopied(true);
415
+ window.setTimeout(() => setCopied(false), 1_200);
416
+ });
417
+ }}
418
+ >
419
+ {copied ? <Check /> : <Clipboard />}
420
+ </button>
421
+ </div>
422
+ );
423
+ }
424
+
425
+ function CustomResult({ message }: { message: WebLiveMessage }) {
426
+ const details = record(message.details);
427
+ if (message.customType === "subagent-result") {
428
+ return (
429
+ <ActivityCard
430
+ family="subagent"
431
+ title={`Subagent ${String(details.id || "")} · ${String(details.title || "result")}`}
432
+ meta={[details.outcome, details.elapsed].filter(Boolean).join(" · ")}
433
+ body={message.content}
434
+ status={canonicalStatus(details.status)}
435
+ />
436
+ );
437
+ }
438
+ if (message.customType === "workflow-result") {
439
+ const entries = Array.isArray(details.entries)
440
+ ? details.entries.map(record)
441
+ : [];
442
+ const statuses = entries.map((entry) => canonicalStatus(entry.status));
443
+ const status: Status = statuses.includes("error")
444
+ ? "error"
445
+ : statuses.includes("warn")
446
+ ? "warn"
447
+ : statuses.length > 0 && statuses.every((value) => value === "done")
448
+ ? "done"
449
+ : statuses.includes("running")
450
+ ? "running"
451
+ : "unknown";
452
+ const body = entries.length
453
+ ? entries
454
+ .map(
455
+ (entry) =>
456
+ `${entry.status === "completed" ? "✓" : "✗"} ${String(entry.summary || entry.runId || "run")}${entry.resultPreview ? `\nResult: ${entry.resultPreview}` : ""}`,
457
+ )
458
+ .join("\n")
459
+ : message.content;
460
+ return (
461
+ <ActivityCard
462
+ family="workflow"
463
+ title={
464
+ entries.length > 1
465
+ ? `Workflow results · ${entries.length} runs`
466
+ : `Workflow ${String(entries[0]?.runId || "result")}`
467
+ }
468
+ body={body}
469
+ status={status}
470
+ />
471
+ );
472
+ }
473
+ return null;
474
+ }
475
+
476
+ function isEmptyToolOutput(content: string) {
477
+ return ["", "[]", "{}", "null"].includes(content.trim());
478
+ }
479
+
480
+ function buildEntries(
481
+ snapshot: WebSnapshot,
482
+ liveMessages: LiveEntry[],
483
+ ): DisplayEntry[] {
484
+ const persisted = snapshot.selectedSession?.entries ?? [];
485
+ const entries = persisted.flatMap((entry: PersistedEntry): DisplayEntry[] =>
486
+ entry.type === "message" && entry.message
487
+ ? [{ key: entry.id, timestamp: entry.timestamp, message: entry.message }]
488
+ : [],
489
+ );
490
+ const signatures = new Set(
491
+ entries.map(
492
+ (entry) => `${entry.message.role || ""}:${entry.message.content}`,
493
+ ),
494
+ );
495
+ for (const live of liveMessages) {
496
+ if (signatures.has(`${live.message.role || ""}:${live.message.content}`))
497
+ continue;
498
+ entries.push({
499
+ key: live.key,
500
+ timestamp: new Date().toISOString(),
501
+ message: live.message,
502
+ });
503
+ }
504
+ return entries;
505
+ }
506
+
507
+ function groupRows(rows: RenderRow[], stepsLabel: string) {
508
+ const blocks: Array<{ grouped: boolean; rows: RenderRow[] }> = [];
509
+ for (const row of rows) {
510
+ const last = blocks.at(-1);
511
+ if (row.groupable && last?.grouped) last.rows.push(row);
512
+ else blocks.push({ grouped: Boolean(row.groupable), rows: [row] });
513
+ }
514
+ return blocks.map((block) => {
515
+ const blockKey = `${block.grouped ? "group" : "rows"}-${block.rows[0]?.key}`;
516
+ if (!block.grouped || block.rows.length < 4) {
517
+ return (
518
+ <Fragment key={blockKey}>
519
+ {block.rows.map((row) => (
520
+ <Fragment key={row.key}>{row.content}</Fragment>
521
+ ))}
522
+ </Fragment>
523
+ );
524
+ }
525
+ return (
526
+ <details
527
+ className={`tool-group ${block.rows.some((row) => row.error) ? "error" : ""}`}
528
+ key={blockKey}
529
+ >
530
+ <summary>
531
+ <span className="details-mark" aria-hidden="true" />
532
+ <span className="tool-group-icons" aria-hidden="true">
533
+ {block.rows.slice(0, 4).map((row) => (
534
+ <Fragment key={row.key}>{row.icon}</Fragment>
535
+ ))}
536
+ </span>
537
+ <span>
538
+ {block.rows.length} {stepsLabel}
539
+ </span>
540
+ </summary>
541
+ <div className="tool-group-body">
542
+ {block.rows.map((row) => (
543
+ <Fragment key={row.key}>{row.content}</Fragment>
544
+ ))}
545
+ </div>
546
+ </details>
547
+ );
548
+ });
549
+ }
550
+
551
+ export function Transcript(props: TranscriptProps) {
552
+ const { t } = useTranslation();
553
+ const viewport = useRef<HTMLDivElement>(null);
554
+ const pinned = useRef(true);
555
+ const lastPath = useRef<string | undefined>(undefined);
556
+ const lastScrollRequest = useRef(props.scrollToBottom);
557
+ const entries = useMemo(
558
+ () => buildEntries(props.snapshot, props.liveMessages),
559
+ [props.snapshot, props.liveMessages],
560
+ );
561
+ const selected = props.snapshot.selectedSession;
562
+ const active = selected?.id === props.snapshot.currentSessionId;
563
+
564
+ const { rows, turns } = useMemo(() => {
565
+ const results = new Map<string, WebLiveMessage>();
566
+ const familyIds = new Set<string>();
567
+ entries.forEach(({ message }) => {
568
+ if (message.role === "toolResult" && message.toolCallId)
569
+ results.set(message.toolCallId, message);
570
+ message.parts?.forEach((part) => {
571
+ if (
572
+ part.type === "toolCall" &&
573
+ part.id &&
574
+ /^(subagent|workflow)/u.test(part.name)
575
+ )
576
+ familyIds.add(part.id);
577
+ });
578
+ });
579
+ const turnItems: Array<{ id: number; title: string }> = [];
580
+ let turn = 0;
581
+ let lastUserIndex = -1;
582
+ for (let index = entries.length - 1; index >= 0; index--) {
583
+ if (entries[index]?.message.role === "user") {
584
+ lastUserIndex = index;
585
+ break;
586
+ }
587
+ }
588
+ const lastAssistantByTurn = new Set<number>();
589
+ let assistantCandidate = -1;
590
+ entries.forEach(({ message }, index) => {
591
+ if (message.role === "user") {
592
+ if (assistantCandidate >= 0)
593
+ lastAssistantByTurn.add(assistantCandidate);
594
+ assistantCandidate = -1;
595
+ } else if (message.role === "assistant" && message.content.trim())
596
+ assistantCandidate = index;
597
+ });
598
+ if (assistantCandidate >= 0) lastAssistantByTurn.add(assistantCandidate);
599
+
600
+ const rendered = entries.flatMap((entry, index): RenderRow[] => {
601
+ const message = entry.message;
602
+ if (message.role === "custom") {
603
+ return [
604
+ {
605
+ key: entry.key,
606
+ content: (
607
+ <article className="message-row assistant detail-only">
608
+ <div className="message-content">
609
+ <CustomResult message={message} />
610
+ </div>
611
+ </article>
612
+ ),
613
+ },
614
+ ];
615
+ }
616
+ if (message.role === "user") {
617
+ turn++;
618
+ turnItems.push({ id: turn, title: turnTitle(message.content) });
619
+ return [
620
+ {
621
+ key: entry.key,
622
+ content: (
623
+ <article className="message-row user" id={`turn-${turn}`}>
624
+ <div className="message-content">
625
+ <div className="message-body">{message.content}</div>
626
+ </div>
627
+ <MessageActions
628
+ content={message.content}
629
+ editable={active && index === lastUserIndex}
630
+ timestamp={entry.timestamp}
631
+ onResend={props.onResend}
632
+ />
633
+ </article>
634
+ ),
635
+ },
636
+ ];
637
+ }
638
+ if (message.role === "assistant") {
639
+ const detailRows: RenderRow[] = [];
640
+ message.parts?.forEach((part, partIndex) => {
641
+ if (part.type === "thinking") {
642
+ const isLive =
643
+ active && props.liveRunning && index === entries.length - 1;
644
+ detailRows.push({
645
+ key: `${entry.key}-thinking-${partIndex}`,
646
+ icon: <Lightbulb />,
647
+ content: (
648
+ <article className="message-row assistant detail-only">
649
+ <div className="message-content">
650
+ <ThinkingEvidence
651
+ body={part.text}
652
+ active={isLive}
653
+ start={props.thinkingStarts[entry.key]}
654
+ duration={props.thinkingDurations[entry.key]}
655
+ />
656
+ </div>
657
+ </article>
658
+ ),
659
+ });
660
+ }
661
+ if (part.type === "toolCall") {
662
+ const result = part.id ? results.get(part.id) : undefined;
663
+ const card = familyCard(part, result);
664
+ const args = parseArguments(part.arguments);
665
+ const toolIcon = iconForTool(part.name);
666
+ detailRows.push({
667
+ key: `${entry.key}-tool-${part.id || partIndex}`,
668
+ groupable: !card,
669
+ error: Boolean(result?.isError),
670
+ icon: toolIcon,
671
+ content: (
672
+ <article className="message-row assistant detail-only">
673
+ <div className="message-content">
674
+ {card || (
675
+ <EvidenceDetails
676
+ body={
677
+ part.name === "bash" &&
678
+ typeof args.command === "string"
679
+ ? args.command
680
+ : part.arguments
681
+ }
682
+ icon={toolIcon}
683
+ name={part.name || "tool"}
684
+ summary={toolSummary(part.name, args)}
685
+ status={resultStatus(result)}
686
+ />
687
+ )}
688
+ </div>
689
+ </article>
690
+ ),
691
+ });
692
+ }
693
+ });
694
+ if (message.content.trim())
695
+ detailRows.push({
696
+ key: `${entry.key}-answer`,
697
+ content: (
698
+ <article className="message-row assistant">
699
+ <div className="message-content">
700
+ <Markdown>{message.content}</Markdown>
701
+ </div>
702
+ {lastAssistantByTurn.has(index) && (
703
+ <MessageActions
704
+ content={message.content}
705
+ editable={false}
706
+ timestamp={entry.timestamp}
707
+ onResend={props.onResend}
708
+ />
709
+ )}
710
+ </article>
711
+ ),
712
+ });
713
+ return detailRows;
714
+ }
715
+ if (message.role === "toolResult") {
716
+ if (message.toolCallId && familyIds.has(message.toolCallId)) return [];
717
+ const family = message.toolName?.startsWith("subagent")
718
+ ? "subagent"
719
+ : message.toolName?.startsWith("workflow")
720
+ ? "workflow"
721
+ : null;
722
+ const status = resultStatus(message);
723
+ const toolName = message.toolName || "tool";
724
+ const icon =
725
+ family === "subagent" ? (
726
+ <Bot key={`${entry.key}-icon`} />
727
+ ) : family === "workflow" ? (
728
+ <Workflow key={`${entry.key}-icon`} />
729
+ ) : (
730
+ iconForTool(toolName)
731
+ );
732
+ const content = family ? (
733
+ <ActivityCard
734
+ key={`${entry.key}-card`}
735
+ family={family}
736
+ title={`${toolName.replaceAll("_", " ")} · ${compactSummary(message.content)}`}
737
+ body={message.content}
738
+ status={status}
739
+ />
740
+ ) : isEmptyToolOutput(message.content) ? (
741
+ <div className="tool-line-empty" key={`${entry.key}-empty`}>
742
+ <span className="tool-icon">{icon}</span>
743
+ <span className="tool-name">{toolName}</span>
744
+ <span className="tool-summary">{t("noOutput")}</span>
745
+ <StatusMark status={status} />
746
+ </div>
747
+ ) : (
748
+ <EvidenceDetails
749
+ key={`${entry.key}-evidence`}
750
+ body={message.content}
751
+ icon={icon}
752
+ name={toolName}
753
+ summary={compactSummary(message.content)}
754
+ status={status}
755
+ />
756
+ );
757
+ return [
758
+ {
759
+ key: entry.key,
760
+ groupable: !family,
761
+ error: status === "error",
762
+ icon,
763
+ content: (
764
+ <article className="message-row assistant detail-only">
765
+ <div className="message-content">{content}</div>
766
+ </article>
767
+ ),
768
+ },
769
+ ];
770
+ }
771
+ return [];
772
+ });
773
+ return { rows: rendered, turns: turnItems };
774
+ }, [
775
+ active,
776
+ entries,
777
+ props.liveRunning,
778
+ props.onResend,
779
+ props.thinkingDurations,
780
+ props.thinkingStarts,
781
+ t,
782
+ ]);
783
+
784
+ useLayoutEffect(() => {
785
+ // Streamed content can grow without changing message keys.
786
+ void entries;
787
+ const element = viewport.current;
788
+ if (!element || !selected) return;
789
+ const changed = lastPath.current !== selected.path;
790
+ const requested = lastScrollRequest.current !== props.scrollToBottom;
791
+ lastScrollRequest.current = props.scrollToBottom;
792
+ if (changed || requested || pinned.current) {
793
+ pinned.current = true;
794
+ if (typeof element.scrollTo === "function") {
795
+ element.scrollTo({ top: element.scrollHeight, behavior: "instant" });
796
+ } else {
797
+ element.scrollTop = element.scrollHeight;
798
+ }
799
+ }
800
+ lastPath.current = selected.path;
801
+ }, [selected, entries, props.scrollToBottom]);
802
+
803
+ const running =
804
+ active &&
805
+ (props.snapshot.runtime.status === "running" || props.liveRunning);
806
+ const runningLabel = props.liveRetry
807
+ ? `${t("modelRetrying")} (${props.liveRetry.attempt}/${props.liveRetry.maxAttempts})`
808
+ : props.livePhase === "preparing"
809
+ ? t("modelPreparing")
810
+ : t("modelRunning");
811
+
812
+ return (
813
+ <>
814
+ <div
815
+ ref={viewport}
816
+ className="conversation"
817
+ role="log"
818
+ aria-label="Conversation"
819
+ onScroll={(event) => {
820
+ const element = event.currentTarget;
821
+ pinned.current =
822
+ element.scrollTop + element.clientHeight >=
823
+ element.scrollHeight - 48;
824
+ }}
825
+ >
826
+ {groupRows(rows, t("stepsLabel"))}
827
+ {running && (
828
+ <div
829
+ className="conversation-running"
830
+ role="status"
831
+ aria-live="polite"
832
+ >
833
+ <span className="conversation-running-dot" />
834
+ <span>{runningLabel}</span>
835
+ </div>
836
+ )}
837
+ </div>
838
+ {turns.length > 1 && (
839
+ <nav className="turn-rail" aria-label={t("conversationTurns")}>
840
+ {turns.map((item) => (
841
+ <button
842
+ key={item.id}
843
+ className="turn-tick"
844
+ type="button"
845
+ title={item.title}
846
+ onClick={() =>
847
+ document
848
+ .getElementById(`turn-${item.id}`)
849
+ ?.scrollIntoView({ block: "start", behavior: "smooth" })
850
+ }
851
+ >
852
+ <span className="turn-tick-mark" />
853
+ <span className="turn-tick-label">{item.title}</span>
854
+ </button>
855
+ ))}
856
+ </nav>
857
+ )}
858
+ </>
859
+ );
860
+ }