@robota-sdk/agent-ui-web 0.0.0-stage → 3.0.0-beta.100
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 +548 -0
- package/LICENSE +661 -0
- package/README.md +19 -2
- package/dist/browser/index.d.ts +736 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +8525 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/node/index.cjs +8575 -0
- package/dist/node/index.d.cts +736 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +736 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +8525 -0
- package/dist/node/index.js.map +1 -0
- package/docs/README.md +62 -0
- package/docs/SPEC.md +130 -0
- package/package.json +99 -3
- package/src/app-view/bridge.ts +217 -0
- package/src/app-view/call-tool-result.ts +58 -0
- package/src/app-view/host-context.ts +37 -0
- package/src/app-view/types.ts +12 -0
- 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/AppView.tsx +147 -0
- package/src/components/Brand.tsx +33 -0
- package/src/components/Composer.tsx +685 -0
- package/src/components/ConversationView.tsx +789 -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 +762 -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 +482 -0
- package/src/components/SessionSurfaceChrome.tsx +319 -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 +95 -0
- package/src/hooks/session-client-types.ts +344 -0
- package/src/hooks/ui-intent-state.ts +74 -0
- package/src/hooks/use-agent-switcher-state.ts +125 -0
- package/src/hooks/use-app-view-requests.ts +178 -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 +1051 -0
- package/src/index.ts +72 -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,319 @@
|
|
|
1
|
+
import { useProductIdentity } from '../product-identity.js';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { ProductWordmark } from './Brand.js';
|
|
6
|
+
|
|
7
|
+
import type { ISessionNotice } from '../hooks/session-client-types.js';
|
|
8
|
+
import type { IWsSessionState } from '../hooks/useSessionClient.js';
|
|
9
|
+
|
|
10
|
+
const STATUS: Record<string, { dot: string; label: string }> = {
|
|
11
|
+
connected: { dot: 'bg-accent status-glow', label: 'Connected' },
|
|
12
|
+
connecting: { dot: 'bg-warning animate-pulse', label: 'Connecting…' },
|
|
13
|
+
disconnected: { dot: 'bg-subtle', label: 'Disconnected' },
|
|
14
|
+
error: { dot: 'bg-destructive', label: 'Connection error' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** #3282 §4c: every view the primary nav can show — 'project' is always offered (desktop and
|
|
18
|
+
* browser alike); 'usage' only where the host opted the surface into it. */
|
|
19
|
+
export type TSessionView = 'chat' | 'usage' | 'project';
|
|
20
|
+
|
|
21
|
+
const VIEW_LABEL: Readonly<Record<TSessionView, string>> = {
|
|
22
|
+
chat: 'Chat',
|
|
23
|
+
usage: 'Usage',
|
|
24
|
+
project: 'Project',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* #3289 §1 — the folder this session works in, wherever `document.title` is settable: a page (the
|
|
29
|
+
* browser tab) and the desktop window that loads it (Electron follows the page's title unless told
|
|
30
|
+
* otherwise, and nothing in `apps/agent-app/electron/main.ts` overrides it — see its own SPEC note).
|
|
31
|
+
* Absent while the host has not said, so a not-yet-connected surface keeps whatever title it loaded
|
|
32
|
+
* with rather than announcing "undefined — the agent".
|
|
33
|
+
*/
|
|
34
|
+
function useWorkspaceDocumentTitle(workspace: { readonly name: string } | undefined): void {
|
|
35
|
+
const { identity } = useProductIdentity();
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (typeof document === 'undefined' || workspace === undefined) return;
|
|
38
|
+
document.title = `${workspace.name} — ${identity.displayName}`;
|
|
39
|
+
}, [workspace, identity.displayName]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The bar over the conversation: the app's name when no sidebar carries it, the workspace folder and
|
|
44
|
+
* the current session's title, the Chat / Usage / Project switch, and the connection state. The
|
|
45
|
+
* state is a dot while all is well and says itself in words once it is not.
|
|
46
|
+
*/
|
|
47
|
+
export function SessionTitleBar({
|
|
48
|
+
status,
|
|
49
|
+
surface,
|
|
50
|
+
title,
|
|
51
|
+
workspace,
|
|
52
|
+
showBrand,
|
|
53
|
+
view,
|
|
54
|
+
onView,
|
|
55
|
+
personalUsageEnabled,
|
|
56
|
+
connectionLost = false,
|
|
57
|
+
}: {
|
|
58
|
+
status: string;
|
|
59
|
+
surface?: string;
|
|
60
|
+
title?: string | null;
|
|
61
|
+
/** The folder this session works in (#3289 §1); absent when the host has not said. */
|
|
62
|
+
workspace?: { readonly name: string; readonly path: string };
|
|
63
|
+
showBrand: boolean;
|
|
64
|
+
view: TSessionView;
|
|
65
|
+
onView: (view: TSessionView) => void;
|
|
66
|
+
personalUsageEnabled: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* #3282 §2 (part 2): mirrors `ConnectionBanner`'s own "give-up" flag (`useWsSession`'s
|
|
69
|
+
* `connectionLost`) so this corner indicator can tell exactly when that banner is on screen, to
|
|
70
|
+
* never say the same thing twice.
|
|
71
|
+
*/
|
|
72
|
+
connectionLost?: boolean;
|
|
73
|
+
}): React.ReactElement {
|
|
74
|
+
const state = STATUS[status] ?? STATUS.disconnected;
|
|
75
|
+
useWorkspaceDocumentTitle(workspace);
|
|
76
|
+
// #3282 §2 (part 2): the same "has this surface ever connected" rule `ConnectionBanner` uses to
|
|
77
|
+
// decide whether it has anything to show — kept in step here so the corner text disappears exactly
|
|
78
|
+
// when, and only when, the banner takes over saying it (never both, never neither).
|
|
79
|
+
const everConnectedRef = useRef(false);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (status === 'connected') everConnectedRef.current = true;
|
|
82
|
+
}, [status]);
|
|
83
|
+
const bannerShown = status !== 'connected' && (connectionLost || everConnectedRef.current);
|
|
84
|
+
// #3282 §4c principle 4 (#3277): everything the agent implements is usable from the GUI through
|
|
85
|
+
// organized screens — the Project panel (git status/diff, project memory) is one, offered next to
|
|
86
|
+
// Chat on every surface, desktop and browser alike; Usage stays surface-gated.
|
|
87
|
+
const items: readonly TSessionView[] = personalUsageEnabled
|
|
88
|
+
? ['chat', 'usage', 'project']
|
|
89
|
+
: ['chat', 'project'];
|
|
90
|
+
return (
|
|
91
|
+
<header
|
|
92
|
+
className="agent-gui-status flex h-12 flex-shrink-0 items-center gap-3 px-5"
|
|
93
|
+
data-status={status}
|
|
94
|
+
>
|
|
95
|
+
{showBrand ? <ProductWordmark surface={surface} /> : null}
|
|
96
|
+
{/* The usage/project views title themselves; the chat view is titled by its session and workspace. */}
|
|
97
|
+
{view === 'chat' && title ? (
|
|
98
|
+
<h1 className="min-w-0 truncate text-[14px] font-medium text-foreground/90">{title}</h1>
|
|
99
|
+
) : null}
|
|
100
|
+
{view === 'chat' && workspace ? (
|
|
101
|
+
<span
|
|
102
|
+
title={workspace.path}
|
|
103
|
+
className="min-w-0 flex-shrink truncate text-[13px] text-muted-foreground"
|
|
104
|
+
>
|
|
105
|
+
{workspace.name}
|
|
106
|
+
</span>
|
|
107
|
+
) : null}
|
|
108
|
+
<div className="ml-auto flex items-center gap-3">
|
|
109
|
+
<nav className="flex items-center rounded-lg bg-raised p-0.5" aria-label="Primary">
|
|
110
|
+
{items.map((item) => (
|
|
111
|
+
<button
|
|
112
|
+
key={item}
|
|
113
|
+
type="button"
|
|
114
|
+
aria-pressed={view === item}
|
|
115
|
+
onClick={() => onView(item)}
|
|
116
|
+
className={`rounded-md px-3 py-1 text-[13px] font-medium transition-colors ${
|
|
117
|
+
view === item
|
|
118
|
+
? 'bg-card text-foreground shadow-sm shadow-black/25'
|
|
119
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
120
|
+
}`}
|
|
121
|
+
>
|
|
122
|
+
{VIEW_LABEL[item]}
|
|
123
|
+
</button>
|
|
124
|
+
))}
|
|
125
|
+
</nav>
|
|
126
|
+
{bannerShown ? null : (
|
|
127
|
+
<span className="flex items-center gap-2" title={state.label}>
|
|
128
|
+
<span className={`h-2 w-2 rounded-full ${state.dot}`} />
|
|
129
|
+
{status === 'connected' ? (
|
|
130
|
+
<span className="sr-only">{status}</span>
|
|
131
|
+
) : (
|
|
132
|
+
<span className="text-[13px] text-muted-foreground">{state.label}</span>
|
|
133
|
+
)}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</header>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The column every part of the conversation shares (matches `SessionSurface`'s `COLUMN`). */
|
|
142
|
+
const BANNER_COLUMN = 'mx-auto w-full max-w-[760px] px-6';
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A connection that drops after a working session (issue #3280 §5): a banner directly above the
|
|
146
|
+
* conversation — never a full-screen replacement — so the conversation and its history stay visible
|
|
147
|
+
* and scrollable throughout. Shown as soon as the drop happens; says nothing on the very first
|
|
148
|
+
* connect, because nothing has been lost yet.
|
|
149
|
+
*/
|
|
150
|
+
export function ConnectionBanner({
|
|
151
|
+
status,
|
|
152
|
+
connectionLost,
|
|
153
|
+
onReconnect,
|
|
154
|
+
}: {
|
|
155
|
+
status: string;
|
|
156
|
+
/** Retries ran out: the runtime is not coming back by itself (`useWsSession`'s `connectionLost`). */
|
|
157
|
+
connectionLost: boolean;
|
|
158
|
+
/** Present only when the host can restart the runtime (desktop); absent in the browser. */
|
|
159
|
+
onReconnect?: () => Promise<void>;
|
|
160
|
+
}): React.ReactElement | null {
|
|
161
|
+
const everConnectedRef = useRef(false);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (status === 'connected') everConnectedRef.current = true;
|
|
164
|
+
}, [status]);
|
|
165
|
+
const { identity } = useProductIdentity();
|
|
166
|
+
const [reconnecting, setReconnecting] = useState(false);
|
|
167
|
+
const [failed, setFailed] = useState<string | null>(null);
|
|
168
|
+
|
|
169
|
+
if (status === 'connected') return null;
|
|
170
|
+
if (!connectionLost && !everConnectedRef.current) return null;
|
|
171
|
+
|
|
172
|
+
if (!connectionLost) {
|
|
173
|
+
return (
|
|
174
|
+
<div role="status" className={`${BANNER_COLUMN} flex-shrink-0 pt-3`}>
|
|
175
|
+
<div className="flex items-center gap-2.5 rounded-xl bg-warning/15 px-4 py-2.5 text-[13.5px] text-foreground">
|
|
176
|
+
<span className="h-2 w-2 flex-shrink-0 animate-pulse rounded-full bg-warning" aria-hidden="true" />
|
|
177
|
+
Connection lost. Reconnecting…
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const reconnect = (): void => {
|
|
184
|
+
if (!onReconnect) return;
|
|
185
|
+
setReconnecting(true);
|
|
186
|
+
setFailed(null);
|
|
187
|
+
onReconnect().catch((error: unknown) => {
|
|
188
|
+
setReconnecting(false);
|
|
189
|
+
setFailed(error instanceof Error ? error.message : String(error));
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div role="alert" className={`${BANNER_COLUMN} flex-shrink-0 pt-3`}>
|
|
195
|
+
<div className="flex flex-col gap-2 rounded-xl bg-destructive/15 px-4 py-3 text-[13.5px] text-foreground">
|
|
196
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
197
|
+
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-destructive" aria-hidden="true" />
|
|
198
|
+
<span className="min-w-0 flex-1">
|
|
199
|
+
{identity.displayName} stopped.
|
|
200
|
+
{!onReconnect && (
|
|
201
|
+
<>
|
|
202
|
+
{' '}
|
|
203
|
+
Run <code className="font-mono">{identity.cliName} --serve --open</code> again to reopen it.
|
|
204
|
+
</>
|
|
205
|
+
)}
|
|
206
|
+
</span>
|
|
207
|
+
{onReconnect ? (
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
onClick={reconnect}
|
|
211
|
+
disabled={reconnecting}
|
|
212
|
+
className="flex-shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[13px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-60"
|
|
213
|
+
>
|
|
214
|
+
{reconnecting ? 'Reconnecting…' : 'Reconnect'}
|
|
215
|
+
</button>
|
|
216
|
+
) : null}
|
|
217
|
+
</div>
|
|
218
|
+
{failed ? <p className="text-destructive">{failed}</p> : null}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The built-in providers whose id does not just-capitalize into their real name (`openai` → `OpenAI`). */
|
|
225
|
+
const PROVIDER_DISPLAY_NAMES: Readonly<Record<string, string>> = {
|
|
226
|
+
openai: 'OpenAI',
|
|
227
|
+
deepseek: 'DeepSeek',
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** `anthropic` → `Anthropic`; a name with its own casing (`openai` → `OpenAI`) uses that instead. */
|
|
231
|
+
function displayProvider(provider: string | undefined): string {
|
|
232
|
+
if (provider === undefined || provider.length === 0) return 'The provider';
|
|
233
|
+
return PROVIDER_DISPLAY_NAMES[provider] ?? provider[0].toUpperCase() + provider.slice(1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A provider/session error classified at the wire boundary (#3289 §3) as one plain sentence naming
|
|
238
|
+
* what happened and, where there is one, the next step — `null` for a notice this classification does
|
|
239
|
+
* not cover, which keeps showing its own `message` instead.
|
|
240
|
+
*
|
|
241
|
+
* `model_unavailable` names `notice.model` — captured on the notice when it was created — and never
|
|
242
|
+
* the session's current model: a still-open notice must keep blaming the model that actually failed
|
|
243
|
+
* even after the person switches to a different one (the very fix the notice suggests).
|
|
244
|
+
*/
|
|
245
|
+
function noticeSentence(notice: ISessionNotice): string | null {
|
|
246
|
+
const provider = displayProvider(notice.provider);
|
|
247
|
+
switch (notice.code) {
|
|
248
|
+
case 'auth':
|
|
249
|
+
return `${provider} rejected the API key. Check the key for this provider.`;
|
|
250
|
+
case 'rate_limit': {
|
|
251
|
+
const wait =
|
|
252
|
+
notice.retryAfterSeconds !== undefined
|
|
253
|
+
? `${notice.retryAfterSeconds} second${notice.retryAfterSeconds === 1 ? '' : 's'}`
|
|
254
|
+
: 'a moment';
|
|
255
|
+
return `${provider} is limiting requests. Try again in ${wait}.`;
|
|
256
|
+
}
|
|
257
|
+
case 'model_unavailable':
|
|
258
|
+
return notice.model
|
|
259
|
+
? `The model "${notice.model}" isn't available with this key.`
|
|
260
|
+
: "The model isn't available with this key.";
|
|
261
|
+
case 'network':
|
|
262
|
+
return `Can't reach ${provider}. Check your connection.`;
|
|
263
|
+
case 'provider':
|
|
264
|
+
return `${provider} returned an error.`;
|
|
265
|
+
default:
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Session outcomes and protocol failures, as dismissible toasts over the top-right corner: they stay until
|
|
272
|
+
* dismissed, and never push the conversation or the composer out of view. Command output is not
|
|
273
|
+
* here — it belongs to the conversation. A provider error classified at the wire boundary shows one
|
|
274
|
+
* plain sentence with the next step; its raw text sits behind "Details" rather than as the headline.
|
|
275
|
+
*/
|
|
276
|
+
export function SessionNotices({ state }: { state: IWsSessionState }): React.ReactElement | null {
|
|
277
|
+
const notices = state.sessionNotices ?? [];
|
|
278
|
+
if (notices.length === 0) return null;
|
|
279
|
+
return (
|
|
280
|
+
<div className="pointer-events-none absolute right-4 top-16 z-40 flex w-[min(400px,calc(100%-32px))] flex-col gap-2">
|
|
281
|
+
{notices.slice(-3).map((notice) => {
|
|
282
|
+
const sentence = noticeSentence(notice);
|
|
283
|
+
const compacted = notice.kind === 'context-compacted';
|
|
284
|
+
return (
|
|
285
|
+
<div
|
|
286
|
+
key={notice.id}
|
|
287
|
+
role={compacted ? 'status' : 'alert'}
|
|
288
|
+
className="gui-rise pointer-events-auto flex items-start gap-3 rounded-xl bg-popover px-4 py-3 text-[14px] leading-snug text-popover-foreground shadow-xl shadow-black/30"
|
|
289
|
+
>
|
|
290
|
+
<span
|
|
291
|
+
className={`mt-[7px] h-2 w-2 flex-shrink-0 rounded-full ${compacted ? 'bg-primary' : 'bg-destructive'}`}
|
|
292
|
+
/>
|
|
293
|
+
<span className="max-h-40 flex-1 overflow-y-auto whitespace-pre-wrap break-words">
|
|
294
|
+
{sentence ?? notice.message}
|
|
295
|
+
{sentence && (
|
|
296
|
+
<details className="mt-1.5">
|
|
297
|
+
<summary className="cursor-pointer text-[12.5px] text-muted-foreground hover:text-foreground">
|
|
298
|
+
Details
|
|
299
|
+
</summary>
|
|
300
|
+
<p className="mt-1 whitespace-pre-wrap break-words text-[12.5px] text-muted-foreground">
|
|
301
|
+
{notice.message}
|
|
302
|
+
</p>
|
|
303
|
+
</details>
|
|
304
|
+
)}
|
|
305
|
+
</span>
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
aria-label="Dismiss notice"
|
|
309
|
+
onClick={() => state.dismissSessionNotice?.(notice.id)}
|
|
310
|
+
className="-mr-1 rounded-md p-1 text-muted-foreground hover:bg-hover hover:text-foreground"
|
|
311
|
+
>
|
|
312
|
+
<X size={15} />
|
|
313
|
+
</button>
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
})}
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useId, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { ISettingsChoice } from '@robota-sdk/agent-interface-session';
|
|
6
|
+
|
|
7
|
+
const SELECT_CLASS =
|
|
8
|
+
'min-w-[160px] flex-shrink-0 rounded-lg border border-border bg-raised px-2.5 py-1.5 text-[13.5px] text-foreground';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One row of the settings form: a plain label, a pop-up menu of `{id, label, description}` choices
|
|
12
|
+
* (never a bare id) and, under it, the description of whichever choice is currently selected — HIG's
|
|
13
|
+
* "a short plain description sits under each control." Selecting a choice applies AT ONCE.
|
|
14
|
+
*/
|
|
15
|
+
export function SettingsPopupField({
|
|
16
|
+
label,
|
|
17
|
+
note,
|
|
18
|
+
value,
|
|
19
|
+
options,
|
|
20
|
+
onChange,
|
|
21
|
+
disabled = false,
|
|
22
|
+
}: {
|
|
23
|
+
label: string;
|
|
24
|
+
/** A fixed note shown under the control instead of the selected choice's own description. */
|
|
25
|
+
note?: string;
|
|
26
|
+
value: string;
|
|
27
|
+
options: readonly ISettingsChoice[];
|
|
28
|
+
onChange: (value: string) => void;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
}): React.ReactElement {
|
|
31
|
+
const id = useId();
|
|
32
|
+
const selected = options.find((option) => option.id === value);
|
|
33
|
+
const description = note ?? selected?.description;
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex flex-col gap-1.5 border-b border-border/60 py-3.5 last:border-b-0">
|
|
36
|
+
<div className="flex items-center justify-between gap-4">
|
|
37
|
+
<label htmlFor={id} className="text-[14px] font-medium text-foreground">
|
|
38
|
+
{label}
|
|
39
|
+
</label>
|
|
40
|
+
<select
|
|
41
|
+
id={id}
|
|
42
|
+
value={options.some((option) => option.id === value) ? value : ''}
|
|
43
|
+
disabled={disabled}
|
|
44
|
+
onChange={(event) => onChange(event.target.value)}
|
|
45
|
+
className={SELECT_CLASS}
|
|
46
|
+
>
|
|
47
|
+
{!options.some((option) => option.id === value) ? (
|
|
48
|
+
<option value="" disabled>
|
|
49
|
+
{value || 'Unset'}
|
|
50
|
+
</option>
|
|
51
|
+
) : null}
|
|
52
|
+
{options.map((option) => (
|
|
53
|
+
<option key={option.id} value={option.id}>
|
|
54
|
+
{option.label}
|
|
55
|
+
</option>
|
|
56
|
+
))}
|
|
57
|
+
</select>
|
|
58
|
+
</div>
|
|
59
|
+
{description ? (
|
|
60
|
+
<p className="text-[12.5px] leading-snug text-muted-foreground">{description}</p>
|
|
61
|
+
) : null}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const OTHER = '__other__';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The language control: a pop-up of the runtime's recommended languages, plus "Other…" — the
|
|
70
|
+
* language is free text (any string the runtime accepts), so the recommended list is a shortcut,
|
|
71
|
+
* never a validated enum. Applies on Enter or on leaving the free-text field.
|
|
72
|
+
*/
|
|
73
|
+
export function SettingsLanguageField({
|
|
74
|
+
label,
|
|
75
|
+
current,
|
|
76
|
+
recommended,
|
|
77
|
+
note,
|
|
78
|
+
onChange,
|
|
79
|
+
disabled = false,
|
|
80
|
+
}: {
|
|
81
|
+
label: string;
|
|
82
|
+
current: string;
|
|
83
|
+
recommended: readonly ISettingsChoice[];
|
|
84
|
+
note: string;
|
|
85
|
+
onChange: (language: string) => void;
|
|
86
|
+
disabled?: boolean;
|
|
87
|
+
}): React.ReactElement {
|
|
88
|
+
const id = useId();
|
|
89
|
+
const isRecommended = recommended.some((choice) => choice.id === current);
|
|
90
|
+
const [showOther, setShowOther] = useState(!isRecommended && current.length > 0);
|
|
91
|
+
const [draft, setDraft] = useState(isRecommended ? '' : current);
|
|
92
|
+
|
|
93
|
+
// A fresh snapshot (e.g. after a switch away and back) re-derives whether "Other…" is showing.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const stillRecommended = recommended.some((choice) => choice.id === current);
|
|
96
|
+
setShowOther(!stillRecommended && current.length > 0);
|
|
97
|
+
setDraft(stillRecommended ? '' : current);
|
|
98
|
+
}, [current, recommended]);
|
|
99
|
+
|
|
100
|
+
function commitDraft(): void {
|
|
101
|
+
const trimmed = draft.trim();
|
|
102
|
+
if (trimmed.length > 0 && trimmed !== current) onChange(trimmed);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="flex flex-col gap-1.5 border-b border-border/60 py-3.5 last:border-b-0">
|
|
107
|
+
<div className="flex items-center justify-between gap-4">
|
|
108
|
+
<label htmlFor={id} className="text-[14px] font-medium text-foreground">
|
|
109
|
+
{label}
|
|
110
|
+
</label>
|
|
111
|
+
<select
|
|
112
|
+
id={id}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
value={showOther ? OTHER : isRecommended ? current : ''}
|
|
115
|
+
onChange={(event) => {
|
|
116
|
+
if (event.target.value === OTHER) {
|
|
117
|
+
setShowOther(true);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
setShowOther(false);
|
|
121
|
+
onChange(event.target.value);
|
|
122
|
+
}}
|
|
123
|
+
className={SELECT_CLASS}
|
|
124
|
+
>
|
|
125
|
+
{!isRecommended && !showOther ? (
|
|
126
|
+
<option value="" disabled>
|
|
127
|
+
{current || 'Unset'}
|
|
128
|
+
</option>
|
|
129
|
+
) : null}
|
|
130
|
+
{recommended.map((choice) => (
|
|
131
|
+
<option key={choice.id} value={choice.id}>
|
|
132
|
+
{choice.label}
|
|
133
|
+
</option>
|
|
134
|
+
))}
|
|
135
|
+
<option value={OTHER}>Other…</option>
|
|
136
|
+
</select>
|
|
137
|
+
</div>
|
|
138
|
+
{showOther ? (
|
|
139
|
+
<input
|
|
140
|
+
type="text"
|
|
141
|
+
aria-label={`${label} — other`}
|
|
142
|
+
placeholder="Language code, e.g. fr"
|
|
143
|
+
value={draft}
|
|
144
|
+
disabled={disabled}
|
|
145
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
146
|
+
onBlur={commitDraft}
|
|
147
|
+
onKeyDown={(event) => {
|
|
148
|
+
if (event.key === 'Enter') {
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
commitDraft();
|
|
151
|
+
}
|
|
152
|
+
}}
|
|
153
|
+
className="rounded-lg border border-border bg-raised px-2.5 py-1.5 text-[13.5px] text-foreground"
|
|
154
|
+
/>
|
|
155
|
+
) : null}
|
|
156
|
+
<p className="text-[12.5px] leading-snug text-muted-foreground">{note}</p>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { SettingsLanguageField, SettingsPopupField } from './SettingsFields.js';
|
|
4
|
+
|
|
5
|
+
import type { ISettingsSnapshot, TSettingsPatch } from '@robota-sdk/agent-interface-session';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Language, output style and preset — reusing the exact choices and descriptions the runtime
|
|
9
|
+
* already builds for `/language`, `/output-style` and `/preset` (#3282 §4a). Never internal ids:
|
|
10
|
+
* every pop-up shows the runtime's human labels.
|
|
11
|
+
*/
|
|
12
|
+
export function SettingsGeneralSection({
|
|
13
|
+
snapshot,
|
|
14
|
+
onUpdate,
|
|
15
|
+
onRequestPresetChange,
|
|
16
|
+
}: {
|
|
17
|
+
snapshot: ISettingsSnapshot;
|
|
18
|
+
onUpdate: (patch: TSettingsPatch) => void;
|
|
19
|
+
/** Routed through the parent so it can confirm a preset that would skip every check. */
|
|
20
|
+
onRequestPresetChange: (presetId: string) => void;
|
|
21
|
+
}): React.ReactElement {
|
|
22
|
+
return (
|
|
23
|
+
<section aria-labelledby="settings-general-heading">
|
|
24
|
+
<h3 id="settings-general-heading" className="sr-only">
|
|
25
|
+
General
|
|
26
|
+
</h3>
|
|
27
|
+
<SettingsLanguageField
|
|
28
|
+
label="Language"
|
|
29
|
+
current={snapshot.language.current}
|
|
30
|
+
recommended={snapshot.language.recommended}
|
|
31
|
+
note={snapshot.language.appliesNote}
|
|
32
|
+
onChange={(language) => onUpdate({ field: 'language', language })}
|
|
33
|
+
/>
|
|
34
|
+
<SettingsPopupField
|
|
35
|
+
label="Output style"
|
|
36
|
+
value={snapshot.outputStyle.current}
|
|
37
|
+
options={snapshot.outputStyle.choices}
|
|
38
|
+
onChange={(styleId) => onUpdate({ field: 'outputStyle', styleId })}
|
|
39
|
+
/>
|
|
40
|
+
<SettingsPopupField
|
|
41
|
+
label="Preset"
|
|
42
|
+
note="Applies to this session — it is not saved for the next one."
|
|
43
|
+
value={snapshot.preset.current}
|
|
44
|
+
options={snapshot.preset.choices}
|
|
45
|
+
onChange={onRequestPresetChange}
|
|
46
|
+
/>
|
|
47
|
+
</section>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronDown, ChevronRight, RefreshCw } from 'lucide-react';
|
|
4
|
+
import React, { useId, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { ISettingsMcpServer, ISettingsSnapshot } from '@robota-sdk/agent-interface-session';
|
|
7
|
+
|
|
8
|
+
const STATUS_LABEL: Readonly<Record<ISettingsMcpServer['status'], string>> = {
|
|
9
|
+
connected: 'Connected',
|
|
10
|
+
failed: 'Failed',
|
|
11
|
+
disabled: 'Disabled',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const STATUS_CLASS: Readonly<Record<ISettingsMcpServer['status'], string>> = {
|
|
15
|
+
connected: 'text-success',
|
|
16
|
+
failed: 'text-destructive',
|
|
17
|
+
disabled: 'text-muted-foreground',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function ServerRow({
|
|
21
|
+
server,
|
|
22
|
+
expanded,
|
|
23
|
+
onToggleExpanded,
|
|
24
|
+
onToggleEnabled,
|
|
25
|
+
}: {
|
|
26
|
+
server: ISettingsMcpServer;
|
|
27
|
+
expanded: boolean;
|
|
28
|
+
onToggleExpanded: () => void;
|
|
29
|
+
onToggleEnabled: (enabled: boolean) => void;
|
|
30
|
+
}): React.ReactElement {
|
|
31
|
+
const labelId = useId();
|
|
32
|
+
const canExpand = server.toolNames.length > 0;
|
|
33
|
+
return (
|
|
34
|
+
<li className="border-b border-border/60 py-3 last:border-b-0">
|
|
35
|
+
<div className="flex items-start justify-between gap-3">
|
|
36
|
+
<div className="min-w-0 flex-1">
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onClick={canExpand ? onToggleExpanded : undefined}
|
|
40
|
+
disabled={!canExpand}
|
|
41
|
+
aria-expanded={canExpand ? expanded : undefined}
|
|
42
|
+
className="flex w-full items-center gap-1.5 text-left disabled:cursor-default"
|
|
43
|
+
>
|
|
44
|
+
{canExpand ? (
|
|
45
|
+
expanded ? (
|
|
46
|
+
<ChevronDown size={14} strokeWidth={1.75} className="flex-shrink-0 text-muted-foreground" />
|
|
47
|
+
) : (
|
|
48
|
+
<ChevronRight size={14} strokeWidth={1.75} className="flex-shrink-0 text-muted-foreground" />
|
|
49
|
+
)
|
|
50
|
+
) : (
|
|
51
|
+
<span className="w-[14px] flex-shrink-0" />
|
|
52
|
+
)}
|
|
53
|
+
<span id={labelId} className="truncate text-[14px] font-medium text-foreground">
|
|
54
|
+
{server.name}
|
|
55
|
+
</span>
|
|
56
|
+
</button>
|
|
57
|
+
<p className="mt-0.5 text-[12.5px] text-muted-foreground">
|
|
58
|
+
{server.scopeLabel} · <span className={STATUS_CLASS[server.status]}>{STATUS_LABEL[server.status]}</span>
|
|
59
|
+
{server.status === 'failed' && server.statusReason ? `: ${server.statusReason}` : ''}
|
|
60
|
+
{' · '}
|
|
61
|
+
{server.toolNames.length === 1 ? '1 tool' : `${server.toolNames.length} tools`}
|
|
62
|
+
</p>
|
|
63
|
+
{expanded && canExpand ? (
|
|
64
|
+
<ul className="mt-2 flex flex-col gap-0.5 pl-[20px]">
|
|
65
|
+
{server.toolNames.map((toolName) => (
|
|
66
|
+
<li key={toolName} className="truncate font-mono text-[12px] text-muted-foreground">
|
|
67
|
+
{toolName}
|
|
68
|
+
</li>
|
|
69
|
+
))}
|
|
70
|
+
</ul>
|
|
71
|
+
) : null}
|
|
72
|
+
</div>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
role="switch"
|
|
76
|
+
aria-checked={server.enabled}
|
|
77
|
+
aria-labelledby={labelId}
|
|
78
|
+
onClick={() => onToggleEnabled(!server.enabled)}
|
|
79
|
+
className={`relative h-6 w-11 flex-shrink-0 rounded-full transition-colors ${
|
|
80
|
+
server.enabled ? 'bg-primary' : 'bg-raised'
|
|
81
|
+
}`}
|
|
82
|
+
>
|
|
83
|
+
<span
|
|
84
|
+
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
|
85
|
+
server.enabled ? 'translate-x-[22px]' : 'translate-x-0.5'
|
|
86
|
+
}`}
|
|
87
|
+
/>
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</li>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The MCP Servers section (#3282 §4 part b-2): every configured server's name, where it is
|
|
96
|
+
* configured, its live connection status and tool count, an enable/disable switch (approve/reject),
|
|
97
|
+
* an expandable list of its tool names, and a section-level Reload. Adding or editing a server is
|
|
98
|
+
* left out — the runtime has no command path that writes MCP config (recorded in
|
|
99
|
+
* `packages/agent-gui-web/docs/SPEC.md`).
|
|
100
|
+
*/
|
|
101
|
+
export function SettingsMcpSection({
|
|
102
|
+
snapshot,
|
|
103
|
+
onToggleServer,
|
|
104
|
+
onReload,
|
|
105
|
+
reloading,
|
|
106
|
+
}: {
|
|
107
|
+
snapshot: ISettingsSnapshot;
|
|
108
|
+
onToggleServer: (serverId: string, enabled: boolean) => void;
|
|
109
|
+
onReload: () => void;
|
|
110
|
+
reloading: boolean;
|
|
111
|
+
}): React.ReactElement {
|
|
112
|
+
const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(new Set());
|
|
113
|
+
const { servers } = snapshot.mcp;
|
|
114
|
+
|
|
115
|
+
function toggleExpanded(id: string): void {
|
|
116
|
+
setExpandedIds((current) => {
|
|
117
|
+
const next = new Set(current);
|
|
118
|
+
if (next.has(id)) next.delete(id);
|
|
119
|
+
else next.add(id);
|
|
120
|
+
return next;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<section aria-labelledby="settings-mcp-heading" className="flex flex-col gap-3">
|
|
126
|
+
<div className="flex items-center justify-between gap-3">
|
|
127
|
+
<h3 id="settings-mcp-heading" className="text-[14px] font-medium text-foreground">
|
|
128
|
+
MCP Servers
|
|
129
|
+
</h3>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
onClick={onReload}
|
|
133
|
+
disabled={reloading}
|
|
134
|
+
className="inline-flex items-center gap-1.5 rounded-lg bg-raised px-2.5 py-1.5 text-[13px] text-foreground hover:bg-hover disabled:opacity-60"
|
|
135
|
+
>
|
|
136
|
+
<RefreshCw size={13} strokeWidth={1.75} className={reloading ? 'animate-spin' : ''} />
|
|
137
|
+
Reload servers
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
{servers.length === 0 ? (
|
|
141
|
+
<p className="text-[13px] leading-snug text-muted-foreground">No MCP servers are configured.</p>
|
|
142
|
+
) : (
|
|
143
|
+
<ul>
|
|
144
|
+
{servers.map((server) => (
|
|
145
|
+
<ServerRow
|
|
146
|
+
key={server.id}
|
|
147
|
+
server={server}
|
|
148
|
+
expanded={expandedIds.has(server.id)}
|
|
149
|
+
onToggleExpanded={() => toggleExpanded(server.id)}
|
|
150
|
+
onToggleEnabled={(enabled) => onToggleServer(server.id, enabled)}
|
|
151
|
+
/>
|
|
152
|
+
))}
|
|
153
|
+
</ul>
|
|
154
|
+
)}
|
|
155
|
+
</section>
|
|
156
|
+
);
|
|
157
|
+
}
|