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