@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.101

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 (81) hide show
  1. package/CHANGELOG.md +557 -0
  2. package/LICENSE +661 -0
  3. package/README.md +19 -2
  4. package/dist/browser/index.d.ts +736 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +8525 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/node/index.cjs +8575 -0
  9. package/dist/node/index.d.cts +736 -0
  10. package/dist/node/index.d.cts.map +1 -0
  11. package/dist/node/index.d.ts +736 -0
  12. package/dist/node/index.d.ts.map +1 -0
  13. package/dist/node/index.js +8525 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/docs/README.md +62 -0
  16. package/docs/SPEC.md +130 -0
  17. package/package.json +99 -3
  18. package/src/app-view/bridge.ts +217 -0
  19. package/src/app-view/call-tool-result.ts +58 -0
  20. package/src/app-view/host-context.ts +37 -0
  21. package/src/app-view/types.ts +12 -0
  22. package/src/client/runtime-host.ts +84 -0
  23. package/src/client/ws-session-client.ts +131 -0
  24. package/src/clipboard.ts +81 -0
  25. package/src/components/AgentActivityPanel.tsx +371 -0
  26. package/src/components/AgentSwitcherSheet.tsx +93 -0
  27. package/src/components/AppView.tsx +147 -0
  28. package/src/components/Brand.tsx +33 -0
  29. package/src/components/Composer.tsx +685 -0
  30. package/src/components/ConversationView.tsx +789 -0
  31. package/src/components/Dialog.tsx +230 -0
  32. package/src/components/DiffLines.tsx +81 -0
  33. package/src/components/ExecutionDetailSheet.tsx +142 -0
  34. package/src/components/HelpSheet.tsx +104 -0
  35. package/src/components/PermissionPrompt.tsx +762 -0
  36. package/src/components/PersonalUsageDashboard.tsx +39 -0
  37. package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
  38. package/src/components/PersonalUsageDashboardSections.tsx +250 -0
  39. package/src/components/PopupMenu.tsx +166 -0
  40. package/src/components/ProjectPanel.tsx +268 -0
  41. package/src/components/SessionMonitor.tsx +180 -0
  42. package/src/components/SessionSidebar.tsx +429 -0
  43. package/src/components/SessionSurface.tsx +482 -0
  44. package/src/components/SessionSurfaceChrome.tsx +319 -0
  45. package/src/components/SettingsFields.tsx +159 -0
  46. package/src/components/SettingsGeneralSection.tsx +49 -0
  47. package/src/components/SettingsMcpSection.tsx +157 -0
  48. package/src/components/SettingsPermissionsSection.tsx +137 -0
  49. package/src/components/SettingsPluginsSection.tsx +165 -0
  50. package/src/components/SettingsProvidersSection.tsx +211 -0
  51. package/src/components/SettingsScreen.tsx +340 -0
  52. package/src/components/StatusControls.tsx +294 -0
  53. package/src/components/composer-attachments.ts +246 -0
  54. package/src/components/personal-usage-dashboard-types.ts +26 -0
  55. package/src/driver-labels.ts +88 -0
  56. package/src/execution-entry-status.ts +55 -0
  57. package/src/goal-view.ts +19 -0
  58. package/src/hooks/command-menu.ts +65 -0
  59. package/src/hooks/excluded-commands.ts +52 -0
  60. package/src/hooks/prompt-state.ts +83 -0
  61. package/src/hooks/server-message-handling.ts +95 -0
  62. package/src/hooks/session-client-types.ts +344 -0
  63. package/src/hooks/ui-intent-state.ts +74 -0
  64. package/src/hooks/use-agent-switcher-state.ts +125 -0
  65. package/src/hooks/use-app-view-requests.ts +178 -0
  66. package/src/hooks/use-execution-detail.ts +118 -0
  67. package/src/hooks/use-media-query.ts +27 -0
  68. package/src/hooks/use-model-list.ts +45 -0
  69. package/src/hooks/use-personal-usage.ts +138 -0
  70. package/src/hooks/use-project-panel.ts +117 -0
  71. package/src/hooks/use-schedules-state.ts +92 -0
  72. package/src/hooks/use-session-directory.ts +202 -0
  73. package/src/hooks/use-settings-state.ts +115 -0
  74. package/src/hooks/useSessionClient.ts +1051 -0
  75. package/src/index.ts +72 -0
  76. package/src/product-identity.tsx +26 -0
  77. package/src/schedule-view.ts +92 -0
  78. package/src/styles/surface.css +231 -0
  79. package/src/styles/surface.css.d.mts +1 -0
  80. package/src/styles/theme.css +38 -0
  81. package/src/styles/theme.css.d.mts +1 -0
@@ -0,0 +1,789 @@
1
+ 'use client';
2
+
3
+ import React, { useRef, useLayoutEffect, useState } from 'react';
4
+ import ReactMarkdown from 'react-markdown';
5
+ import remarkGfm from 'remark-gfm';
6
+ import {
7
+ ArrowDown,
8
+ Bot,
9
+ Check,
10
+ ChevronDown,
11
+ ChevronRight,
12
+ Copy,
13
+ FileText,
14
+ Globe,
15
+ LoaderCircle,
16
+ Pencil,
17
+ Search,
18
+ SquareTerminal,
19
+ Wrench,
20
+ } from 'lucide-react';
21
+
22
+ import { useCopyFeedback } from '../clipboard.js';
23
+ import { driverAttributionText, isSameSurface } from '../driver-labels.js';
24
+ import { AppView } from './AppView.js';
25
+ import { DiffLines } from './DiffLines.js';
26
+
27
+ import type {
28
+ IActiveTool,
29
+ IChangedFileSummary,
30
+ ICommandOutputEntry,
31
+ TConversationEntry,
32
+ } from '../hooks/useSessionClient.js';
33
+ import type { TDriverId } from '@robota-sdk/agent-interface-session';
34
+
35
+ /** The plain text of a react-markdown children tree — used to copy a code block's real source. */
36
+ function nodeText(node: React.ReactNode): string {
37
+ if (node === null || node === undefined || typeof node === 'boolean') return '';
38
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
39
+ if (Array.isArray(node)) return node.map(nodeText).join('');
40
+ if (React.isValidElement(node)) {
41
+ return nodeText((node.props as { children?: React.ReactNode }).children);
42
+ }
43
+ return '';
44
+ }
45
+
46
+ /**
47
+ * "Copy code" / "Copy message" (#3289 §2): shows on hover and keyboard focus, always on touch (no
48
+ * `hover` capability to gate on there). The transient "Copied" / "Couldn't copy" feedback lives in a
49
+ * visually hidden `aria-live` region that is a SIBLING of the button, not a descendant of it — ARIA
50
+ * name computation only ever looks at an element's own subtree, so a live region placed anywhere
51
+ * inside the button would still contribute its (mutating) text to the button's accessible name even
52
+ * while merely `sr-only`-clipped rather than `aria-hidden`, silently turning a constant name like
53
+ * "Copy code" into a moving target such as "Copy code Copied". Keeping the two siblings instead gives
54
+ * the button a name computed purely from its own constant visible label, and lets the live region
55
+ * announce without touching that name or moving focus off the button. (An explicit `aria-label` on the
56
+ * button would also pin the name, but collides here: Playwright's `getByLabel` matches on the
57
+ * `aria-label` attribute by substring, and "Copy message" would then match any lookup for the
58
+ * composer's own `aria-label="message"`.) A live region doubling as the button's own sole
59
+ * name-contributing content is the most fragile shape of all: it held the label text itself here at
60
+ * first, and Chromium's accessibility tree then failed to expose that name at all after the button
61
+ * remounted (streaming text settling into a message) under load — caught by the browser e2e, not the
62
+ * jsdom unit tests, which never touch the real accessibility tree.
63
+ */
64
+ function CopyButton({
65
+ label,
66
+ getText,
67
+ className = '',
68
+ }: {
69
+ label: string;
70
+ getText: () => string;
71
+ className?: string;
72
+ }): React.ReactElement {
73
+ const { status, copy } = useCopyFeedback();
74
+ const feedback = status === 'copied' ? 'Copied' : status === 'error' ? "Couldn't copy" : '';
75
+ return (
76
+ <span className={`inline-flex items-center ${className}`}>
77
+ <button
78
+ type="button"
79
+ onClick={() => copy(getText())}
80
+ className="inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-card px-2 py-1 text-[12px] font-medium text-muted-foreground shadow-sm shadow-black/5 transition-colors hover:bg-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
81
+ >
82
+ {status === 'copied' ? (
83
+ <Check size={13} strokeWidth={2} />
84
+ ) : (
85
+ <Copy size={13} strokeWidth={1.9} />
86
+ )}
87
+ <span>{label}</span>
88
+ </button>
89
+ <span className="sr-only" role="status" aria-live="polite">
90
+ {feedback}
91
+ </span>
92
+ </span>
93
+ );
94
+ }
95
+
96
+ /**
97
+ * A fenced code block: unwrapped and horizontally scrollable (a visible scrollbar via
98
+ * `.gui-code-scroll`, `tabIndex=0` and a name so a keyboard user without a mouse wheel can still reach
99
+ * the rest of a long line), with its own "Copy code" button revealed on hover/focus (#3289 §2).
100
+ */
101
+ function CodeBlockRenderer({ children }: { children?: React.ReactNode }): React.ReactElement {
102
+ const text = nodeText(children).replace(/\n$/, '');
103
+ return (
104
+ <div className="group/code relative my-1">
105
+ <pre
106
+ tabIndex={0}
107
+ aria-label="Code block, horizontally scrollable"
108
+ className="gui-code-scroll overflow-x-auto rounded-xl bg-sidebar px-4 py-3.5 font-mono text-[13px] leading-[1.65] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
109
+ >
110
+ {children}
111
+ </pre>
112
+ <CopyButton
113
+ label="Copy code"
114
+ getText={() => text}
115
+ className="absolute right-2 top-2 opacity-0 transition-opacity group-hover/code:opacity-100 group-focus-within/code:opacity-100 [@media(hover:none)]:opacity-100"
116
+ />
117
+ </div>
118
+ );
119
+ }
120
+
121
+ interface IConversationViewProps {
122
+ messages: readonly TConversationEntry[];
123
+ activeTools: IActiveTool[];
124
+ streamingText: string;
125
+ isThinking: boolean;
126
+ /** This connection's own driver id (§3289 §3), threaded to every message it renders. */
127
+ ownDriverId: TDriverId | null;
128
+ }
129
+
130
+ /** Agent markdown, set as reading prose: headings step down gently, code sits on its own quiet surface. */
131
+ function AgentMarkdown({ children }: { children: string }): React.ReactElement {
132
+ return (
133
+ <ReactMarkdown
134
+ remarkPlugins={[remarkGfm]}
135
+ components={{
136
+ h1: ({ children: c }) => (
137
+ <h1 className="mt-6 text-[20px] font-semibold leading-snug tracking-[-0.015em] first:mt-0">
138
+ {c}
139
+ </h1>
140
+ ),
141
+ h2: ({ children: c }) => (
142
+ <h2 className="mt-6 text-[17px] font-semibold leading-snug tracking-[-0.01em] first:mt-0">
143
+ {c}
144
+ </h2>
145
+ ),
146
+ h3: ({ children: c }) => (
147
+ <h3 className="mt-5 text-[15px] font-semibold leading-snug first:mt-0">{c}</h3>
148
+ ),
149
+ p: ({ children: c }) => <p>{c}</p>,
150
+ pre: CodeBlockRenderer,
151
+ code: ({ className, children: c }) => {
152
+ const isBlock = Boolean(className);
153
+ return isBlock ? (
154
+ <code className={`font-mono ${className ?? ''}`}>{c}</code>
155
+ ) : (
156
+ <code className="rounded-md bg-raised px-1.5 py-0.5 font-mono text-[0.85em]">{c}</code>
157
+ );
158
+ },
159
+ ul: ({ children: c }) => (
160
+ <ul className="ml-5 list-outside list-disc space-y-1.5 marker:text-subtle">{c}</ul>
161
+ ),
162
+ ol: ({ children: c }) => (
163
+ <ol className="ml-5 list-outside list-decimal space-y-1.5 marker:text-subtle">{c}</ol>
164
+ ),
165
+ li: ({ children: c }) => <li className="pl-1">{c}</li>,
166
+ strong: ({ children: c }) => <strong className="font-semibold">{c}</strong>,
167
+ em: ({ children: c }) => <em className="italic">{c}</em>,
168
+ a: ({ href, children: c }) => (
169
+ <a
170
+ href={href}
171
+ className="text-accent underline decoration-accent/40 underline-offset-[3px] hover:decoration-accent"
172
+ target="_blank"
173
+ rel="noopener noreferrer"
174
+ >
175
+ {c}
176
+ </a>
177
+ ),
178
+ table: ({ children: c }) => (
179
+ <div className="overflow-x-auto">
180
+ <table className="w-full border-collapse text-[14px]">{c}</table>
181
+ </div>
182
+ ),
183
+ thead: ({ children: c }) => <thead>{c}</thead>,
184
+ th: ({ children: c }) => (
185
+ <th className="border-b border-border px-3 py-2 text-left text-[13px] font-medium text-muted-foreground">
186
+ {c}
187
+ </th>
188
+ ),
189
+ td: ({ children: c }) => <td className="px-3 py-2 align-top">{c}</td>,
190
+ blockquote: ({ children: c }) => (
191
+ <blockquote className="border-l-2 border-subtle/50 pl-4 text-muted-foreground">
192
+ {c}
193
+ </blockquote>
194
+ ),
195
+ hr: () => <hr className="mx-auto my-7 w-12 border-subtle/40" />,
196
+ }}
197
+ >
198
+ {children}
199
+ </ReactMarkdown>
200
+ );
201
+ }
202
+
203
+ function UserBlock({
204
+ content,
205
+ author,
206
+ ownDriverId,
207
+ }: {
208
+ content: string;
209
+ author?: string;
210
+ /** This connection's own driver id (§3289 §3): its own turns never carry a "from" label. */
211
+ ownDriverId: TDriverId | null;
212
+ }): React.ReactElement {
213
+ // REMOTE-014 E5 (display-only, OWNER PRINCIPLE): show WHO drove this turn when it wasn't this
214
+ // connection's own — in plain words, never the raw server-assigned id. #3288 §1: AGENT_DRIVER_ID
215
+ // (never "the same surface" — see isSameSurface) reads "Automatic — loop": today it is only ever
216
+ // a loop's own injected turn (requestWakeup in interactive-session.ts).
217
+ const coDriver =
218
+ author && !isSameSurface(author, ownDriverId) ? driverAttributionText(author) : undefined;
219
+ return (
220
+ <div className="flex flex-col items-end gap-1.5 pl-12">
221
+ {coDriver && <span className="px-1 text-[12.5px] text-subtle">{coDriver}</span>}
222
+ <div className="max-w-full whitespace-pre-wrap break-words rounded-[20px] bg-raised px-4 py-2.5 text-[15px] leading-relaxed text-foreground">
223
+ {content}
224
+ </div>
225
+ </div>
226
+ );
227
+ }
228
+
229
+ function AgentBlock({
230
+ content,
231
+ isStreaming = false,
232
+ }: {
233
+ content: string;
234
+ isStreaming?: boolean;
235
+ }): React.ReactElement {
236
+ return (
237
+ <div className="group/message gui-prose">
238
+ <AgentMarkdown>{content}</AgentMarkdown>
239
+ {isStreaming && (
240
+ <span className="ml-1 inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-foreground/70 align-middle" />
241
+ )}
242
+ {/* #3289 §2: every assistant message can copy its own Markdown source; a still-streaming one
243
+ has nothing settled yet to copy, so the button waits for it to finish. */}
244
+ {!isStreaming && (
245
+ <div className="flex opacity-0 transition-opacity focus-within:opacity-100 group-hover/message:opacity-100 group-focus-within/message:opacity-100 [@media(hover:none)]:opacity-100">
246
+ <CopyButton label="Copy message" getText={() => content} />
247
+ </div>
248
+ )}
249
+ </div>
250
+ );
251
+ }
252
+
253
+ /** A recognisable glyph for the common tools; anything else is a wrench. */
254
+ function ToolIcon({ name, className }: { name: string; className?: string }): React.ReactElement {
255
+ const key = name.toLowerCase();
256
+ const props = { size: 15, strokeWidth: 1.75, className };
257
+ if (/(read|view|cat|open)/u.test(key)) return <FileText {...props} />;
258
+ if (/(grep|glob|search|find|list|ls)/u.test(key)) return <Search {...props} />;
259
+ if (/(edit|write|patch|replace|create)/u.test(key)) return <Pencil {...props} />;
260
+ if (/(bash|shell|exec|run|command|terminal)/u.test(key)) return <SquareTerminal {...props} />;
261
+ if (/(web|fetch|http|url|browse)/u.test(key)) return <Globe {...props} />;
262
+ if (/(agent|task|delegate)/u.test(key)) return <Bot {...props} />;
263
+ return <Wrench {...props} />;
264
+ }
265
+
266
+ /**
267
+ * #3288: a long path's directory is shortened FROM THE LEFT so the filename — the part someone
268
+ * actually needs to recognise — is never the part that gets cut.
269
+ */
270
+ const PATH_DISPLAY_MAX_CHARS = 52;
271
+ function shortenDirectoryFromLeft(path: string, maxChars: number = PATH_DISPLAY_MAX_CHARS): string {
272
+ if (path.length <= maxChars) return path;
273
+ const lastSlash = path.lastIndexOf('/');
274
+ if (lastSlash === -1) return path; // no directory part to shorten — the filename is never cut
275
+ const fileName = path.slice(lastSlash + 1);
276
+ const dir = path.slice(0, lastSlash);
277
+ const budget = maxChars - fileName.length - 2; // room for the leading "…/"
278
+ if (budget <= 0) return `…/${fileName}`;
279
+ return `…${dir.slice(dir.length - budget)}/${fileName}`;
280
+ }
281
+
282
+ /** Best-effort read of a tool's raw JSON result payload (`IToolInvocationResult`-shaped). */
283
+ function parseToolResult(
284
+ raw: string | undefined,
285
+ ): { output?: string; error?: string; exitCode?: number; structured?: string } | undefined {
286
+ if (!raw) return undefined;
287
+ try {
288
+ const value: unknown = JSON.parse(raw);
289
+ if (typeof value !== 'object' || value === null) return { output: raw };
290
+ const parsed = value as Record<string, unknown>;
291
+ return {
292
+ structured: raw,
293
+ ...(typeof parsed.output === 'string' && parsed.output ? { output: parsed.output } : {}),
294
+ ...(typeof parsed.error === 'string' && parsed.error ? { error: parsed.error } : {}),
295
+ ...(typeof parsed.exitCode === 'number' ? { exitCode: parsed.exitCode } : {}),
296
+ };
297
+ } catch {
298
+ // allow-fallback: not the expected JSON shape — show it as plain text rather than nothing.
299
+ return { output: raw };
300
+ }
301
+ }
302
+
303
+ /** Long output folds after ~20 lines; "Show more" expands up to a hard cap with a trailing note. */
304
+ const OUTPUT_FOLD_LINES = 20;
305
+ const OUTPUT_EXPANDED_CAP_LINES = 500;
306
+ function FoldedOutput({ text }: { text: string }): React.ReactElement {
307
+ const [open, setOpen] = useState(false);
308
+ const lines = text.split('\n');
309
+ if (lines.length <= OUTPUT_FOLD_LINES) {
310
+ return (
311
+ <pre className="whitespace-pre-wrap break-words font-mono text-[12.5px] leading-relaxed text-muted-foreground">
312
+ {text}
313
+ </pre>
314
+ );
315
+ }
316
+ const capped = lines.slice(0, OUTPUT_EXPANDED_CAP_LINES);
317
+ const shown = open ? capped : lines.slice(0, OUTPUT_FOLD_LINES);
318
+ return (
319
+ <div className="flex flex-col gap-1">
320
+ <pre className="whitespace-pre-wrap break-words font-mono text-[12.5px] leading-relaxed text-muted-foreground">
321
+ {shown.join('\n')}
322
+ </pre>
323
+ {open && lines.length > OUTPUT_EXPANDED_CAP_LINES && (
324
+ <p className="text-[12px] text-subtle">
325
+ {lines.length - OUTPUT_EXPANDED_CAP_LINES} more lines
326
+ </p>
327
+ )}
328
+ <button
329
+ type="button"
330
+ onClick={() => setOpen((value) => !value)}
331
+ className="self-start text-[12.5px] text-accent hover:underline"
332
+ >
333
+ {open ? 'Show less' : `Show more (${lines.length - OUTPUT_FOLD_LINES} more lines)`}
334
+ </button>
335
+ </div>
336
+ );
337
+ }
338
+
339
+ function ToolObservations({ tool }: { tool: IActiveTool }): React.ReactElement {
340
+ return (
341
+ <div className="flex flex-col gap-2">
342
+ {tool.executionId && <code className="text-[12px] text-subtle">{tool.executionId}</code>}
343
+ {tool.toolResultParts?.map((part, index) => {
344
+ switch (part.type) {
345
+ case 'text':
346
+ return <FoldedOutput key={index} text={part.text} />;
347
+ case 'image_inline':
348
+ if (/^image\/(png|jpeg|gif|webp)$/.test(part.mimeType)) {
349
+ return (
350
+ <img
351
+ key={index}
352
+ src={`data:${part.mimeType};base64,${part.data}`}
353
+ alt={`Observation from ${tool.name}`}
354
+ className="max-h-96 max-w-full object-contain"
355
+ />
356
+ );
357
+ }
358
+ return <p key={index}>Image preview unavailable ({part.mimeType}).</p>;
359
+ case 'image_uri':
360
+ return (
361
+ <div key={index}>
362
+ <p>Image reference; an admitted read is required.</p>
363
+ <code>{part.uri}</code>
364
+ </div>
365
+ );
366
+ case 'resource_link':
367
+ return (
368
+ <div key={index}>
369
+ <p>{part.title ?? part.name}</p>
370
+ <code>{part.uri}</code>
371
+ <p>An admitted read is required.</p>
372
+ </div>
373
+ );
374
+ case 'resource_embedded':
375
+ return (
376
+ <div key={index}>
377
+ <code>{part.uri}</code>
378
+ {part.text !== undefined ? (
379
+ <FoldedOutput text={part.text} />
380
+ ) : (
381
+ <p>
382
+ Embedded binary resource ({part.mimeType ?? 'unknown type'}); preview
383
+ unavailable.
384
+ </p>
385
+ )}
386
+ </div>
387
+ );
388
+ case 'audio_inline':
389
+ return <p key={index}>Audio preview unavailable ({part.mimeType}).</p>;
390
+ }
391
+ })}
392
+ </div>
393
+ );
394
+ }
395
+
396
+ function ToolCard({ tool }: { tool: IActiveTool }): React.ReactElement {
397
+ const running = tool.status === 'running';
398
+ const failed = tool.status === 'error';
399
+ const [open, setOpen] = useState(false);
400
+ const parsedResult = parseToolResult(tool.toolResultData);
401
+ const hasDiff = tool.diffLines !== undefined && tool.diffLines.length > 0;
402
+ const hasOutput =
403
+ Boolean(parsedResult?.output) ||
404
+ Boolean(parsedResult?.error) ||
405
+ Boolean(parsedResult?.structured);
406
+ const hasObservations = Boolean(tool.toolResultParts?.length);
407
+ const expandable = hasDiff || hasOutput || hasObservations;
408
+ // #3288: a projected `/command` tool shows its command, never the internal provider tool name.
409
+ const label = tool.commandName ? `Ran /${tool.commandName}` : tool.name;
410
+
411
+ return (
412
+ <div className="flex flex-col text-[14px]">
413
+ <button
414
+ type="button"
415
+ disabled={!expandable}
416
+ aria-expanded={expandable ? open : undefined}
417
+ onClick={() => expandable && setOpen((value) => !value)}
418
+ className={`group -mx-2 flex min-w-0 items-center gap-2.5 rounded-lg px-2 py-1 text-left ${
419
+ expandable ? 'hover:bg-hover' : 'cursor-default'
420
+ }`}
421
+ >
422
+ <ToolIcon
423
+ name={tool.name}
424
+ className={`flex-shrink-0 ${failed ? 'text-destructive' : 'text-subtle'}`}
425
+ />
426
+ <span
427
+ className={`flex-shrink-0 font-medium ${
428
+ running ? 'gui-shimmer' : failed ? 'text-destructive' : 'text-muted-foreground'
429
+ }`}
430
+ >
431
+ {label}
432
+ </span>
433
+ {tool.displayPath ? (
434
+ // #3288: server-computed, workspace-relative, and never middle/end-truncated — only the
435
+ // directory portion is shortened, from the left, so the filename always reads in full.
436
+ <span
437
+ className="min-w-0 flex-1 break-all font-mono text-[12.5px] text-subtle"
438
+ title={tool.displayPath}
439
+ >
440
+ {shortenDirectoryFromLeft(tool.displayPath)}
441
+ </span>
442
+ ) : (
443
+ typeof tool.input === 'string' &&
444
+ tool.input && (
445
+ <span className="min-w-0 truncate font-mono text-[12.5px] text-subtle">
446
+ {tool.input}
447
+ </span>
448
+ )
449
+ )}
450
+ <span className="ml-auto flex flex-shrink-0 items-center gap-1.5 text-[12.5px] text-subtle">
451
+ {running ? (
452
+ <LoaderCircle size={14} className="animate-spin" aria-label="running" />
453
+ ) : failed ? (
454
+ <span className="text-destructive">failed</span>
455
+ ) : (
456
+ <Check size={14} aria-label="done" />
457
+ )}
458
+ {expandable && (
459
+ <ChevronRight size={13} className={`transition-transform ${open ? 'rotate-90' : ''}`} />
460
+ )}
461
+ </span>
462
+ </button>
463
+ {open && expandable && (
464
+ <div className="flex flex-col gap-1 pb-1 pl-[25px] pt-0.5">
465
+ {hasDiff && <DiffLines diffLines={tool.diffLines!} />}
466
+ {!hasDiff && parsedResult?.output && <FoldedOutput text={parsedResult.output} />}
467
+ {parsedResult?.error && (
468
+ <p className="font-mono text-[12.5px] text-destructive">{parsedResult.error}</p>
469
+ )}
470
+ {typeof parsedResult?.exitCode === 'number' && (
471
+ <p className="text-[12px] text-subtle">exit {parsedResult.exitCode}</p>
472
+ )}
473
+ {parsedResult?.structured && <FoldedOutput text={parsedResult.structured} />}
474
+ {hasObservations && <ToolObservations tool={tool} />}
475
+ </div>
476
+ )}
477
+ </div>
478
+ );
479
+ }
480
+
481
+ /**
482
+ * Command output taller than this starts folded, so one reply never buries the conversation. Lines
483
+ * count where layout cannot be measured; a rendered card also folds on its measured height, because
484
+ * a few long wrapped lines are as tall as many short ones.
485
+ */
486
+ const COMMAND_FOLD_LINES = 12;
487
+ const COMMAND_FOLD_HEIGHT_PX = 280;
488
+
489
+ const COMMAND_TONE: Record<ICommandOutputEntry['tone'], { dot: string; text: string }> = {
490
+ success: { dot: 'bg-accent', text: 'text-foreground/90' },
491
+ error: { dot: 'bg-destructive', text: 'text-destructive' },
492
+ info: { dot: 'bg-subtle', text: 'text-muted-foreground' },
493
+ };
494
+
495
+ /**
496
+ * A slash command's outcome, where it was typed: line breaks kept, long output folded. #3289 §2 — the
497
+ * card itself still marks the command (the `/name` header stays monospace, a fixed-width label rather
498
+ * than prose), but its BODY reads as normal text — proportional, normal size — unless it is actually a
499
+ * long listing (the same measure that decides whether to fold it: many raw lines, or tall once
500
+ * rendered), which stays monospace so columns of a real dump still line up.
501
+ */
502
+ function CommandCard({ entry }: { entry: ICommandOutputEntry }): React.ReactElement {
503
+ const lines = entry.content.split('\n');
504
+ const bodyRef = useRef<HTMLPreElement>(null);
505
+ const [tall, setTall] = useState(false);
506
+ const [open, setOpen] = useState(false);
507
+ useLayoutEffect(() => {
508
+ const body = bodyRef.current;
509
+ if (body) setTall(body.scrollHeight > COMMAND_FOLD_HEIGHT_PX);
510
+ }, [entry.content]);
511
+ const byLines = lines.length > COMMAND_FOLD_LINES;
512
+ const foldable = byLines || tall;
513
+ const folded = foldable && !open;
514
+ const shown = folded && byLines ? lines.slice(0, COMMAND_FOLD_LINES).join('\n') : entry.content;
515
+ const tone = COMMAND_TONE[entry.tone];
516
+ return (
517
+ <div
518
+ data-testid="command-output"
519
+ data-tone={entry.tone}
520
+ className="overflow-hidden rounded-xl bg-card"
521
+ >
522
+ <div className="flex items-center gap-2 px-4 pt-3 text-[13px]">
523
+ <span className={`h-1.5 w-1.5 rounded-full ${tone.dot}`} />
524
+ <span className="font-mono font-medium text-foreground">/{entry.name}</span>
525
+ </div>
526
+ <div className="relative">
527
+ <pre
528
+ ref={bodyRef}
529
+ style={folded ? { maxHeight: COMMAND_FOLD_HEIGHT_PX } : undefined}
530
+ className={`overflow-hidden whitespace-pre-wrap break-words px-4 pb-3 pt-1.5 leading-[1.65] ${
531
+ foldable ? 'font-mono text-[13px]' : 'font-sans text-[14.5px]'
532
+ } ${tone.text}`}
533
+ >
534
+ {shown}
535
+ </pre>
536
+ {folded && (
537
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-card to-transparent" />
538
+ )}
539
+ </div>
540
+ {foldable && (
541
+ <button
542
+ type="button"
543
+ onClick={() => setOpen((value) => !value)}
544
+ className="flex w-full items-center gap-1.5 px-4 pb-2.5 pt-1 text-left text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
545
+ >
546
+ <ChevronDown size={14} className={open ? 'rotate-180' : ''} />
547
+ {open ? 'Show less' : `Show all ${lines.length} lines`}
548
+ </button>
549
+ )}
550
+ </div>
551
+ );
552
+ }
553
+
554
+ /** One line per finished turn's tool calls; the calls themselves open on demand. */
555
+ function ToolGroup({ tools }: { tools: readonly IActiveTool[] }): React.ReactElement | null {
556
+ const [open, setOpen] = useState(false);
557
+ // #3288: an internal signal tool (e.g. the goal-status tool) is never shown as a call — the goal
558
+ // bar shows progress instead. A group made up ENTIRELY of internal tools renders nothing at all.
559
+ const visible = tools.filter((tool) => !tool.internal);
560
+ const failed = visible.filter((tool) => tool.status === 'error').length;
561
+ const names = [
562
+ ...new Set(visible.map((tool) => (tool.commandName ? `/${tool.commandName}` : tool.name))),
563
+ ].join(', ');
564
+ if (visible.length === 0) return null;
565
+ return (
566
+ <div className="text-[14px]">
567
+ <button
568
+ type="button"
569
+ aria-expanded={open}
570
+ onClick={() => setOpen((value) => !value)}
571
+ className="group -mx-2 flex max-w-full items-center gap-2.5 rounded-lg px-2 py-1 text-left text-muted-foreground hover:bg-hover hover:text-foreground"
572
+ >
573
+ <Wrench size={15} strokeWidth={1.75} className="flex-shrink-0 text-subtle" />
574
+ <span className="flex-shrink-0">
575
+ {visible.length} tool {visible.length === 1 ? 'call' : 'calls'}
576
+ </span>
577
+ <span className="min-w-0 truncate text-subtle">{names}</span>
578
+ {failed > 0 && (
579
+ <span className="flex-shrink-0 rounded-md bg-destructive/12 px-1.5 text-[12.5px] text-destructive">
580
+ {failed} failed
581
+ </span>
582
+ )}
583
+ <ChevronRight
584
+ size={14}
585
+ className={`flex-shrink-0 text-subtle transition-transform ${open ? 'rotate-90' : ''}`}
586
+ />
587
+ </button>
588
+ {/* A call's app is the point of the call, so it shows without opening the group — and only
589
+ here: the rows below never repeat it. */}
590
+ {visible
591
+ .filter((tool) => tool.appView !== undefined)
592
+ .map((tool) => (
593
+ <div key={tool.id} className="pl-[25px]">
594
+ <AppView tool={tool} />
595
+ </div>
596
+ ))}
597
+ {open && (
598
+ <div className="mt-0.5 flex flex-col pl-[25px]">
599
+ {visible.map((tool) => (
600
+ <ToolCard key={tool.id} tool={tool} />
601
+ ))}
602
+ </div>
603
+ )}
604
+ </div>
605
+ );
606
+ }
607
+
608
+ /** #3288: a turn that changed files ends with this compact row — a click opens that file's diff. */
609
+ function ChangedFilesRow({ files }: { files: readonly IChangedFileSummary[] }): React.ReactElement {
610
+ const [openPath, setOpenPath] = useState<string | null>(null);
611
+ return (
612
+ <div className="flex flex-col gap-0.5 rounded-lg bg-card px-2 py-1.5 text-[13px]">
613
+ <p className="px-1 text-[12px] font-medium text-muted-foreground">Changed files</p>
614
+ {files.map((file) => (
615
+ <div key={file.path} className="flex flex-col">
616
+ <button
617
+ type="button"
618
+ aria-expanded={openPath === file.path}
619
+ onClick={() => setOpenPath((current) => (current === file.path ? null : file.path))}
620
+ className="flex min-w-0 items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-hover"
621
+ >
622
+ <span
623
+ className="min-w-0 flex-1 break-all font-mono text-[12.5px] text-foreground"
624
+ title={file.path}
625
+ >
626
+ {shortenDirectoryFromLeft(file.path)}
627
+ </span>
628
+ <span className="flex-shrink-0 font-mono text-[12px] text-accent">+{file.added}</span>
629
+ <span className="flex-shrink-0 font-mono text-[12px] text-destructive">
630
+ -{file.removed}
631
+ </span>
632
+ </button>
633
+ {openPath === file.path && (
634
+ <div className="pl-2">
635
+ <DiffLines diffLines={file.diffLines} />
636
+ </div>
637
+ )}
638
+ </div>
639
+ ))}
640
+ </div>
641
+ );
642
+ }
643
+
644
+ function ThinkingIndicator(): React.ReactElement {
645
+ return <p className="gui-shimmer w-fit text-[15px] font-medium">Thinking…</p>;
646
+ }
647
+
648
+ /** "At the bottom" for auto-scroll purposes — within this many px of the true bottom. */
649
+ const NEAR_BOTTOM_PX = 80;
650
+
651
+ export function ConversationView({
652
+ messages,
653
+ activeTools,
654
+ streamingText,
655
+ isThinking,
656
+ ownDriverId,
657
+ }: IConversationViewProps): React.ReactElement {
658
+ const containerRef = useRef<HTMLElement>(null);
659
+ // The source of truth `handleScroll` and the follow effect both read synchronously (a ref, so
660
+ // neither sees a stale value from its own last render); mirrored into state to re-render the "Jump
661
+ // to latest" button.
662
+ const isAtBottomRef = useRef(true);
663
+ const [isAtBottom, setIsAtBottomState] = useState(true);
664
+ // New content arrived while the person was scrolled away from the bottom — cleared the moment they
665
+ // return to it, by hand or via "Jump to latest".
666
+ const [hasNewBelow, setHasNewBelow] = useState(false);
667
+
668
+ const setIsAtBottom = (value: boolean): void => {
669
+ isAtBottomRef.current = value;
670
+ setIsAtBottomState(value);
671
+ };
672
+
673
+ /**
674
+ * #3289 §2 — follows new content only while the person is already at the bottom: streaming text, a
675
+ * finished message, a tool starting or finishing. Expanding something already on screen (a tool row,
676
+ * a folded command card) touches none of these, so it never causes a jump — the effect simply does
677
+ * not run for it. An instant jump rather than a smooth one, so it can never itself be mistaken by
678
+ * `handleScroll` below for the person scrolling away mid-animation.
679
+ */
680
+ useLayoutEffect(() => {
681
+ if (isAtBottomRef.current) {
682
+ const el = containerRef.current;
683
+ if (el) el.scrollTop = el.scrollHeight;
684
+ setIsAtBottom(true);
685
+ setHasNewBelow(false);
686
+ } else {
687
+ setHasNewBelow(true);
688
+ }
689
+ }, [messages.length, streamingText, isThinking, activeTools.length]);
690
+
691
+ const handleScroll = (): void => {
692
+ const el = containerRef.current;
693
+ if (!el) return;
694
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
695
+ setIsAtBottom(atBottom);
696
+ // Returning to the bottom by hand (not just via "Jump to latest") also clears the "new content
697
+ // below" flag — otherwise it stays stale and re-shows the button on a later scroll-up even though
698
+ // nothing new has arrived since.
699
+ if (atBottom) setHasNewBelow(false);
700
+ };
701
+
702
+ const jumpToLatest = (): void => {
703
+ const el = containerRef.current;
704
+ if (el) el.scrollTop = el.scrollHeight;
705
+ setIsAtBottom(true);
706
+ setHasNewBelow(false);
707
+ };
708
+
709
+ const isEmpty =
710
+ messages.length === 0 && !isThinking && activeTools.length === 0 && !streamingText;
711
+ const isReplyStreaming =
712
+ isThinking || streamingText.length > 0 || activeTools.some((tool) => tool.status === 'running');
713
+ const showJumpToLatest = !isAtBottom && (hasNewBelow || isReplyStreaming);
714
+
715
+ return (
716
+ <div className="agent-ui relative flex h-full min-h-0 flex-col">
717
+ <main
718
+ ref={containerRef}
719
+ onScroll={handleScroll}
720
+ className="min-h-0 flex-1 overflow-y-auto"
721
+ aria-label="Conversation"
722
+ >
723
+ <div className="mx-auto flex w-full max-w-[760px] flex-col gap-6 px-6 pb-6 pt-8">
724
+ {isEmpty && (
725
+ <div className="flex h-full items-center justify-center">
726
+ <p className="text-[14px] text-subtle">No messages yet</p>
727
+ </div>
728
+ )}
729
+
730
+ {messages.map((entry) => {
731
+ switch (entry.role) {
732
+ case 'user':
733
+ return (
734
+ <UserBlock
735
+ key={entry.id}
736
+ content={entry.content}
737
+ author={entry.author}
738
+ ownDriverId={ownDriverId}
739
+ />
740
+ );
741
+ case 'assistant':
742
+ return <AgentBlock key={entry.id} content={entry.content} />;
743
+ case 'command':
744
+ return <CommandCard key={entry.id} entry={entry} />;
745
+ case 'tools':
746
+ return <ToolGroup key={entry.id} tools={entry.tools} />;
747
+ case 'changed-files':
748
+ return <ChangedFilesRow key={entry.id} files={entry.files} />;
749
+ }
750
+ })}
751
+
752
+ {isThinking && !streamingText && <ThinkingIndicator />}
753
+
754
+ {activeTools.some((tool) => !tool.internal) && (
755
+ <div className="flex flex-col">
756
+ {activeTools
757
+ .filter((tool) => !tool.internal)
758
+ .map((tool) => (
759
+ <React.Fragment key={tool.id}>
760
+ <ToolCard tool={tool} />
761
+ {tool.appView !== undefined && tool.status !== 'running' && (
762
+ <div className="pl-[25px]">
763
+ <AppView tool={tool} />
764
+ </div>
765
+ )}
766
+ </React.Fragment>
767
+ ))}
768
+ </div>
769
+ )}
770
+
771
+ {streamingText && <AgentBlock content={streamingText} isStreaming />}
772
+ </div>
773
+ </main>
774
+
775
+ {showJumpToLatest && (
776
+ <div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center">
777
+ <button
778
+ type="button"
779
+ onClick={jumpToLatest}
780
+ className="pointer-events-auto flex items-center gap-1.5 rounded-full bg-primary px-3.5 py-2 text-[13px] font-medium text-primary-foreground shadow-lg shadow-black/25 hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
781
+ >
782
+ <ArrowDown size={14} strokeWidth={2.25} />
783
+ Jump to latest
784
+ </button>
785
+ </div>
786
+ )}
787
+ </div>
788
+ );
789
+ }