@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +409 -0
  2. package/LICENSE +661 -0
  3. package/README.md +19 -2
  4. package/dist/browser/index.d.ts +715 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +7923 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/node/index.cjs +7973 -0
  9. package/dist/node/index.d.cts +715 -0
  10. package/dist/node/index.d.cts.map +1 -0
  11. package/dist/node/index.d.ts +715 -0
  12. package/dist/node/index.d.ts.map +1 -0
  13. package/dist/node/index.js +7923 -0
  14. package/dist/node/index.js.map +1 -0
  15. package/docs/README.md +62 -0
  16. package/docs/SPEC.md +124 -0
  17. package/package.json +99 -3
  18. package/src/client/runtime-host.ts +84 -0
  19. package/src/client/ws-session-client.ts +131 -0
  20. package/src/clipboard.ts +81 -0
  21. package/src/components/AgentActivityPanel.tsx +371 -0
  22. package/src/components/AgentSwitcherSheet.tsx +93 -0
  23. package/src/components/Brand.tsx +33 -0
  24. package/src/components/Composer.tsx +685 -0
  25. package/src/components/ConversationView.tsx +772 -0
  26. package/src/components/Dialog.tsx +230 -0
  27. package/src/components/DiffLines.tsx +81 -0
  28. package/src/components/ExecutionDetailSheet.tsx +142 -0
  29. package/src/components/HelpSheet.tsx +104 -0
  30. package/src/components/PermissionPrompt.tsx +661 -0
  31. package/src/components/PersonalUsageDashboard.tsx +39 -0
  32. package/src/components/PersonalUsageDashboardDetails.tsx +368 -0
  33. package/src/components/PersonalUsageDashboardSections.tsx +250 -0
  34. package/src/components/PopupMenu.tsx +166 -0
  35. package/src/components/ProjectPanel.tsx +268 -0
  36. package/src/components/SessionMonitor.tsx +180 -0
  37. package/src/components/SessionSidebar.tsx +429 -0
  38. package/src/components/SessionSurface.tsx +455 -0
  39. package/src/components/SessionSurfaceChrome.tsx +316 -0
  40. package/src/components/SettingsFields.tsx +159 -0
  41. package/src/components/SettingsGeneralSection.tsx +49 -0
  42. package/src/components/SettingsMcpSection.tsx +157 -0
  43. package/src/components/SettingsPermissionsSection.tsx +137 -0
  44. package/src/components/SettingsPluginsSection.tsx +165 -0
  45. package/src/components/SettingsProvidersSection.tsx +211 -0
  46. package/src/components/SettingsScreen.tsx +340 -0
  47. package/src/components/StatusControls.tsx +294 -0
  48. package/src/components/composer-attachments.ts +246 -0
  49. package/src/components/personal-usage-dashboard-types.ts +26 -0
  50. package/src/driver-labels.ts +88 -0
  51. package/src/execution-entry-status.ts +55 -0
  52. package/src/goal-view.ts +19 -0
  53. package/src/hooks/command-menu.ts +65 -0
  54. package/src/hooks/excluded-commands.ts +52 -0
  55. package/src/hooks/prompt-state.ts +83 -0
  56. package/src/hooks/server-message-handling.ts +92 -0
  57. package/src/hooks/session-client-types.ts +329 -0
  58. package/src/hooks/ui-intent-state.ts +74 -0
  59. package/src/hooks/use-agent-switcher-state.ts +125 -0
  60. package/src/hooks/use-execution-detail.ts +118 -0
  61. package/src/hooks/use-media-query.ts +27 -0
  62. package/src/hooks/use-model-list.ts +45 -0
  63. package/src/hooks/use-personal-usage.ts +138 -0
  64. package/src/hooks/use-project-panel.ts +117 -0
  65. package/src/hooks/use-schedules-state.ts +92 -0
  66. package/src/hooks/use-session-directory.ts +202 -0
  67. package/src/hooks/use-settings-state.ts +115 -0
  68. package/src/hooks/useSessionClient.ts +991 -0
  69. package/src/index.ts +66 -0
  70. package/src/product-identity.tsx +26 -0
  71. package/src/schedule-view.ts +92 -0
  72. package/src/styles/surface.css +231 -0
  73. package/src/styles/surface.css.d.mts +1 -0
  74. package/src/styles/theme.css +38 -0
  75. package/src/styles/theme.css.d.mts +1 -0
@@ -0,0 +1,661 @@
1
+ 'use client';
2
+
3
+ import { MessageCircleQuestion, ShieldAlert } from 'lucide-react';
4
+ import React, {
5
+ forwardRef,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useLayoutEffect,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
12
+
13
+ import { driverAttributionText, isSameSurface } from '../driver-labels.js';
14
+ import { ConfirmDialog } from './Dialog.js';
15
+ import { DiffLines } from './DiffLines.js';
16
+
17
+ import type { TPendingPrompt } from '../hooks/prompt-state.js';
18
+ import type { TActionResponse } from '@robota-sdk/agent-interface-transport';
19
+ import type { TDriverId } from '@robota-sdk/agent-interface-session';
20
+
21
+ /**
22
+ * Renders the owner's pending permission/ask prompts (REMOTE-007/009). Under local == remote the paired
23
+ * browser owner answers its OWN prompts; the first pending prompt is shown. A gated tool call blocks
24
+ * until answered, so this is on the critical path — not decorative.
25
+ *
26
+ * `modal` covers the page; `dock` sits above the composer, as desktop agent apps place a pending
27
+ * question next to where the answer is typed. Once armed and focused, its keys answer it: 1–9 picks
28
+ * an option, Esc cancels a question or denies a permission — but it never takes that focus away from
29
+ * a field the person is typing in (#3280 §1). A click on a button always answers at once. An ask whose
30
+ * request sets `allowFreeText` also shows a text field (masked when `masked` is set); Enter there
31
+ * submits it, and its own keys never reach the prompt's shortcuts (#3280 §3). The field's typed value
32
+ * lives only in `FreeTextField`, keyed by `prompt.id`: a cancelled or answered prompt's text can
33
+ * never reach the next prompt's render, masked or not, because the field is a fresh component
34
+ * instance rather than a value carried over in this component's own state (#3280 §3 follow-up).
35
+ */
36
+ interface IPermissionPromptProps {
37
+ prompts: readonly TPendingPrompt[];
38
+ onAnswerPermission: (id: string, result: boolean) => void;
39
+ onAnswerAsk: (id: string, response: TActionResponse) => void;
40
+ /** This connection's own driver id (§3289 §3): a prompt raised by its own turn shows no requester. */
41
+ ownDriverId?: TDriverId | null;
42
+ layout?: 'modal' | 'dock';
43
+ /** How long a new prompt waits before its keys answer it; tests pass their own. */
44
+ armDelayMs?: number;
45
+ /**
46
+ * Called once an answer (click or key) leaves no prompt behind, if focus was inside this prompt at
47
+ * the time — so the caller can send it back to the field the person was typing in (the composer,
48
+ * for the `dock` layout). Not called when another prompt immediately follows: focus stays on the
49
+ * prompt for that one instead.
50
+ */
51
+ onFocusReturn?: () => void;
52
+ }
53
+
54
+ /**
55
+ * A prompt can appear while the user is typing in a field. For this long after it appears the prompt
56
+ * leaves focus where it is, so the keystrokes land there instead of answering (`1` allows). A mouse
57
+ * click on a button still answers at once, and Esc still denies or cancels, because both are
58
+ * deliberate. A click made with the keyboard (Enter or Space on a focused button) waits for the
59
+ * prompt to arm like any other key.
60
+ */
61
+ export const PROMPT_ARM_DELAY_MS = 450;
62
+
63
+ /** An element whose keystrokes the person relies on — a prompt must never take focus from one of these. */
64
+ function isEditableElement(element: Element | null): boolean {
65
+ return element !== null && element.matches('input, textarea, select, [contenteditable]');
66
+ }
67
+
68
+ /**
69
+ * #3282 §2 (part 2) — the one destructive option in the provider profile action menu (the ask built
70
+ * by `askProviderProfileAction` in `agent-command`'s `provider-command-profile.ts`, `request.id`
71
+ * `'provider-profile-action'`): "Delete" answers to no digit and renders apart from Switch/Edit/Test/
72
+ * Duplicate, styled destructive — the exact audited bug ("Delete looks the same as Switch, answers to
73
+ * the key 5"). Every other ask (this `request.id` check is the only thing that scopes the change) is
74
+ * unaffected.
75
+ */
76
+ function isDestructiveAskOption(requestId: string | undefined, value: string): boolean {
77
+ return requestId === 'provider-profile-action' && value === 'delete';
78
+ }
79
+
80
+ export function PermissionPrompt({
81
+ prompts,
82
+ onAnswerPermission,
83
+ onAnswerAsk,
84
+ ownDriverId = null,
85
+ layout = 'modal',
86
+ armDelayMs = PROMPT_ARM_DELAY_MS,
87
+ onFocusReturn,
88
+ }: IPermissionPromptProps): React.ReactElement | null {
89
+ const prompt = prompts[0];
90
+ const promptId = prompt?.id;
91
+ const containerRef = useRef<HTMLDivElement>(null);
92
+ const fieldRef = useRef<HTMLInputElement>(null);
93
+ // A question with a text field (#3280 §3): the field is the prompt's primary control, so focus goes
94
+ // there instead of the container wherever this component would otherwise focus the container itself.
95
+ const hasFreeText = prompt?.kind === 'ask' && prompt.request.allowFreeText === true;
96
+ const [armedId, setArmedId] = useState<string | undefined>(undefined);
97
+ const [hasFocus, setHasFocus] = useState(false);
98
+ // Whether the field itself (not just some part of the prompt) currently holds focus — digits typed
99
+ // there type into the field rather than choosing a numbered option, so the hint must say so. Lifted
100
+ // from `FreeTextField` via `onFocusChange`, since the hint text is rendered up here.
101
+ const [fieldFocused, setFieldFocused] = useState(false);
102
+ // Imperative escape hatch onto the field's own state (see `FreeTextField`): every path that ends
103
+ // this prompt clears it explicitly, rather than relying on the field ever being asked to render a
104
+ // value that belongs to a prompt other than the one it was mounted for.
105
+ const fieldHandleRef = useRef<IFreeTextFieldHandle>(null);
106
+ // Set at the moment of an answer if focus was inside the prompt then; consumed once the prompt list
107
+ // empties (see below) or cleared once a next prompt shows it was not needed after all.
108
+ const pendingFocusReturnRef = useRef(false);
109
+ useEffect(() => {
110
+ if (promptId === undefined || armDelayMs <= 0) return undefined;
111
+ const timer = setTimeout(() => setArmedId(promptId), armDelayMs);
112
+ return () => clearTimeout(timer);
113
+ }, [promptId, armDelayMs]);
114
+ const armed = promptId !== undefined && (armDelayMs <= 0 || armedId === promptId);
115
+ // Answering one prompt renders the next into the same buttons, so a button focused to answer the
116
+ // last one would take Enter, Space or a held key as an answer to this one. Focus goes back to the
117
+ // prompt itself, whose keys wait for the prompt to arm. A reused button is still there to find this
118
+ // way — but the free-text field is its own component, remounted (not updated) for the new prompt.id
119
+ // (#3280 §3 follow-up), and removing a focused DOM node drops focus to <body> as an intrinsic side
120
+ // effect, before this effect ever runs. `pendingFocusReturnRef` (set at answer time, and not yet
121
+ // cleared — the effect that clears it is a passive one, ordered after this layout effect) still
122
+ // remembers that focus was in this prompt, so that case is reclaimed too.
123
+ useLayoutEffect(() => {
124
+ const container = containerRef.current;
125
+ if (!container) return;
126
+ const active = document.activeElement;
127
+ const focusStillInside = !!(active && active !== container && container.contains(active));
128
+ const focusJustLeftOnRemount = pendingFocusReturnRef.current && active === document.body;
129
+ if (focusStillInside || focusJustLeftOnRemount) {
130
+ container.focus();
131
+ }
132
+ }, [promptId]);
133
+ // Once armed the prompt takes focus, so its keys answer it rather than type wherever the person
134
+ // already is — UNLESS that is an editable field, whose keystrokes must never be stolen (#3280 §1).
135
+ useEffect(() => {
136
+ if (!armed) return;
137
+ if (isEditableElement(document.activeElement)) return;
138
+ (hasFreeText ? fieldRef.current : containerRef.current)?.focus();
139
+ }, [armed, promptId, hasFreeText]);
140
+ // An answer given while focus was inside this prompt sends it back to the composer once no prompt is
141
+ // left to show it — unless another one immediately follows (handled above by keeping focus in place).
142
+ useEffect(() => {
143
+ if (promptId !== undefined) {
144
+ pendingFocusReturnRef.current = false;
145
+ return;
146
+ }
147
+ setHasFocus(false);
148
+ if (pendingFocusReturnRef.current) {
149
+ pendingFocusReturnRef.current = false;
150
+ onFocusReturn?.();
151
+ }
152
+ }, [promptId, onFocusReturn]);
153
+ if (!prompt) return null;
154
+
155
+ // #3282 §2 (part 2): the provider-delete confirmation (`buildProviderDelete` in agent-command's
156
+ // `provider-command-profile-lifecycle.ts`) is now built on the shared `ConfirmDialog` (#3331)
157
+ // instead of the generic ask grid — a destructive-styled "Delete", Cancel focused by default, and
158
+ // no accidental backdrop-click dismissal. `confirmAction`'s own `CONFIRM_YES`/`CONFIRM_NO` values
159
+ // ('yes'/'no', from `@robota-sdk/agent-core`) are answered directly; this bypasses the rest of this
160
+ // component's dock/modal chrome and digit-shortcut machinery entirely, since `Dialog` already owns
161
+ // its own focus trap, Esc handling and focus restore.
162
+ if (prompt.kind === 'ask' && prompt.request.id === 'provider-delete') {
163
+ return (
164
+ <ConfirmDialog
165
+ open
166
+ title={prompt.request.title}
167
+ body="This removes it from your provider profiles. This can't be undone."
168
+ confirmLabel="Delete"
169
+ destructive
170
+ onCancel={() => onAnswerAsk(prompt.id, { type: 'cancelled' })}
171
+ onConfirm={() => onAnswerAsk(prompt.id, { type: 'answer', values: ['yes'] })}
172
+ />
173
+ );
174
+ }
175
+
176
+ // REMOTE-014 E5 (display-only): the prompt belongs to the driver whose turn raised it. Shown so the owner
177
+ // can tell a co-driver's tool-gate from their own — it NEVER changes who is authorized to answer (owner).
178
+ const requesterLabel =
179
+ prompt.requesterDriverId && !isSameSurface(prompt.requesterDriverId, ownDriverId)
180
+ ? driverAttributionText(prompt.requesterDriverId)
181
+ : undefined;
182
+
183
+ const dock = layout === 'dock';
184
+ /** Records whether the answer being given leaves the prompt able to hand focus back afterwards. */
185
+ const rememberFocus = (): void => {
186
+ const container = containerRef.current;
187
+ const active = document.activeElement;
188
+ pendingFocusReturnRef.current = !!(container && active && container.contains(active));
189
+ };
190
+ const answerPermission = (id: string, result: boolean): void => {
191
+ rememberFocus();
192
+ onAnswerPermission(id, result);
193
+ };
194
+ // Every ask answer — submit, Cancel, Esc (in the field or at the dock), or picking an option while
195
+ // text sits untyped in the field — clears whatever the field holds first. `FreeTextField` also
196
+ // clears itself on its own submit/Esc paths; this is the single choke point that covers the rest
197
+ // (the Cancel button, the dock's Esc, and answering by option) without every call site repeating it.
198
+ const answerAsk = (id: string, response: TActionResponse): void => {
199
+ rememberFocus();
200
+ fieldHandleRef.current?.clear();
201
+ onAnswerAsk(id, response);
202
+ };
203
+ /** A click anywhere in the prompt that isn't on a button or the field itself focuses the field. */
204
+ const onContainerClick = (event: React.MouseEvent<HTMLDivElement>): void => {
205
+ if (!hasFreeText) return;
206
+ if ((event.target as HTMLElement).closest('button, input')) return;
207
+ fieldRef.current?.focus();
208
+ };
209
+ /** A mouse click answers at once; a keyboard click (`detail` 0) waits for the prompt to arm. */
210
+ const onButton =
211
+ (answer: () => void) =>
212
+ (event: React.MouseEvent): void => {
213
+ if (!armed && event.detail === 0) return;
214
+ answer();
215
+ };
216
+ const onDockKey = (event: React.KeyboardEvent): void => {
217
+ if (event.key === 'Escape') {
218
+ event.preventDefault();
219
+ if (prompt.kind === 'permission') answerPermission(prompt.id, false);
220
+ else answerAsk(prompt.id, { type: 'cancelled' });
221
+ return;
222
+ }
223
+ // A key that reaches a prompt still arming (it kept focus from the one before) is dropped.
224
+ if (!armed) return;
225
+ const index = Number(event.key) - 1;
226
+ if (!Number.isInteger(index) || index < 0) return;
227
+ if (prompt.kind === 'permission') {
228
+ if (index < 2) answerPermission(prompt.id, index === 0);
229
+ return;
230
+ }
231
+ // #3282 §2: a destructive option (Delete) is excluded from the digit-answerable list entirely —
232
+ // no stray digit, including the one it happened to render at before this menu was reordered, can
233
+ // ever answer it.
234
+ const digitOptions = (prompt.request.options ?? []).filter(
235
+ (opt) => !isDestructiveAskOption(prompt.request.id, opt.value),
236
+ );
237
+ const option = digitOptions[index];
238
+ if (option) answerAsk(prompt.id, { type: 'answer', values: [option.value] });
239
+ };
240
+ // #3288: an Edit/Write request carries the same server-built diff `tool_end` would show.
241
+ const hasDiff = prompt.kind === 'permission' && (prompt.diffLines?.length ?? 0) > 0;
242
+ const shellCommand =
243
+ prompt.kind === 'permission' && typeof prompt.toolArgs['command'] === 'string'
244
+ ? prompt.toolArgs['command']
245
+ : undefined;
246
+ const isShellCommand = prompt.kind === 'permission' && prompt.toolName === 'Bash' && shellCommand !== undefined;
247
+ const askRequestId = prompt.kind === 'ask' ? prompt.request.id : undefined;
248
+ const askOptions = prompt.kind === 'ask' ? (prompt.request.options ?? []) : [];
249
+ // #3282 §2: Delete (in the provider profile action menu) renders apart from the rest, destructive-
250
+ // styled and with no digit shortcut — every other ask's options are unaffected (`destructiveAskOption`
251
+ // stays undefined, so `primaryAskOptions` is just `askOptions`, same order, same shortcuts).
252
+ const destructiveAskOption = askOptions.find((opt) => isDestructiveAskOption(askRequestId, opt.value));
253
+ const primaryAskOptions = destructiveAskOption
254
+ ? askOptions.filter((opt) => opt !== destructiveAskOption)
255
+ : askOptions;
256
+ // Digits choose an option only while focus is on the prompt itself, not the field — a hint promising
257
+ // "1–9 choose" while the field holds focus would be wrong, since digits type there instead.
258
+ const askArmedHint =
259
+ hasFreeText && fieldFocused
260
+ ? 'Enter to submit · Esc to cancel'
261
+ : askOptions.length > 0
262
+ ? '1–9 choose · Esc cancel'
263
+ : 'Esc cancels';
264
+ return (
265
+ <div
266
+ className={
267
+ dock
268
+ ? 'agent-ui gui-rise flex-shrink-0'
269
+ : 'agent-ui fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4 backdrop-blur-[2px]'
270
+ }
271
+ >
272
+ <div
273
+ ref={containerRef}
274
+ tabIndex={dock ? 0 : -1}
275
+ onKeyDown={dock ? onDockKey : undefined}
276
+ onClick={onContainerClick}
277
+ onFocus={() => setHasFocus(true)}
278
+ onBlur={(event) => {
279
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
280
+ setHasFocus(false);
281
+ }
282
+ }}
283
+ role="dialog"
284
+ aria-label="pending question"
285
+ data-armed={dock ? String(armed) : undefined}
286
+ className={`w-full rounded-2xl bg-card text-[14px] text-card-foreground shadow-[0_8px_30px_-12px_rgb(0_0_0/0.45)] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
287
+ dock ? 'p-4' : 'max-w-lg p-5'
288
+ }`}
289
+ >
290
+ {prompt.kind === 'permission' ? (
291
+ <>
292
+ <div className="flex items-start gap-3">
293
+ <span className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-warning/15 text-warning">
294
+ <ShieldAlert size={17} strokeWidth={1.9} />
295
+ </span>
296
+ <div className="min-w-0 flex-1">
297
+ <p className="text-[13px] text-muted-foreground">
298
+ Permission request
299
+ {requesterLabel && (
300
+ <>
301
+ {' '}
302
+ · <span className="text-foreground">{requesterLabel}</span>
303
+ </>
304
+ )}
305
+ </p>
306
+ {/* #3288 §2: an Edit/Write request with a server-built diff preview shows "Edit
307
+ <path>" (or "Background agent <label> wants to edit <path>") and the diff, in
308
+ place of "Allow <tool> to run?" and raw arguments.
309
+ Issue #3288 §1: a background agent's own request ALWAYS names it — whether or
310
+ not there's a diff to show — so this never reads as an unattributed ask for
311
+ exactly the riskiest calls (a diff-bearing Edit/Write). */}
312
+ {prompt.requester?.kind === 'background-agent' ? (
313
+ <p className="mt-0.5 text-[15px] font-medium text-foreground">
314
+ Background agent <span className="font-semibold">{prompt.requester.label}</span>{' '}
315
+ wants to{' '}
316
+ {hasDiff ? (
317
+ <>
318
+ {prompt.toolName.toLowerCase()}{' '}
319
+ <span className="font-mono font-semibold">{prompt.diffFile}</span>
320
+ </>
321
+ ) : (
322
+ <>
323
+ run <span className="font-semibold">{prompt.toolName}</span>
324
+ </>
325
+ )}
326
+ </p>
327
+ ) : hasDiff ? (
328
+ <p className="mt-0.5 text-[15px] font-medium text-foreground">
329
+ {prompt.toolName}{' '}
330
+ <span className="font-mono font-semibold">{prompt.diffFile}</span>
331
+ </p>
332
+ ) : (
333
+ <p className="mt-0.5 text-[15px] font-medium text-foreground">
334
+ Allow <span className="font-semibold">{prompt.toolName}</span> to run?
335
+ </p>
336
+ )}
337
+ {hasDiff ? (
338
+ <div className="mt-2.5">
339
+ <DiffLines diffLines={prompt.diffLines!} />
340
+ </div>
341
+ ) : isShellCommand ? (
342
+ <ShellCommandPreview command={shellCommand} cwd={prompt.cwd} />
343
+ ) : (
344
+ <ToolArgs args={prompt.toolArgs} />
345
+ )}
346
+ </div>
347
+ </div>
348
+ <div className="mt-4 flex flex-wrap items-center gap-2 pl-11">
349
+ <button
350
+ type="button"
351
+ aria-keyshortcuts={dock ? '1' : undefined}
352
+ className={PRIMARY_BUTTON}
353
+ onClick={onButton(() => answerPermission(prompt.id, true))}
354
+ >
355
+ <span>Allow</span>
356
+ {dock && <Kbd>1</Kbd>}
357
+ </button>
358
+ <button
359
+ type="button"
360
+ aria-keyshortcuts={dock ? '2' : undefined}
361
+ className={SECONDARY_BUTTON}
362
+ onClick={onButton(() => answerPermission(prompt.id, false))}
363
+ >
364
+ <span>Deny</span>
365
+ {dock && <Kbd>2</Kbd>}
366
+ </button>
367
+ {dock && (
368
+ <KeyHint hasFocus={hasFocus} armed={armed} armedText="1 allow · 2 deny · Esc deny" />
369
+ )}
370
+ </div>
371
+ </>
372
+ ) : (
373
+ <>
374
+ <div className="flex items-start gap-3">
375
+ <span className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
376
+ <MessageCircleQuestion size={17} strokeWidth={1.9} />
377
+ </span>
378
+ <div className="min-w-0 flex-1">
379
+ {requesterLabel && (
380
+ <p className="text-[13px] text-muted-foreground">
381
+ <span className="text-foreground">{requesterLabel}</span>
382
+ </p>
383
+ )}
384
+ <p className="text-[15px] font-medium text-foreground">{prompt.request.title}</p>
385
+ {prompt.request.description ? (
386
+ <p className="mt-1 leading-relaxed text-muted-foreground">
387
+ {prompt.request.description}
388
+ </p>
389
+ ) : null}
390
+ </div>
391
+ </div>
392
+ <div className="mt-4 flex flex-col gap-3 pl-11">
393
+ {primaryAskOptions.length > 0 && (
394
+ <div className="flex flex-wrap items-start gap-3">
395
+ {primaryAskOptions.map((opt, index) => (
396
+ <div key={opt.value} className="flex flex-col items-start gap-0.5">
397
+ <button
398
+ type="button"
399
+ aria-keyshortcuts={dock && index < 9 ? String(index + 1) : undefined}
400
+ className={index === 0 ? PRIMARY_BUTTON : SECONDARY_BUTTON}
401
+ onClick={onButton(() =>
402
+ answerAsk(prompt.id, { type: 'answer', values: [opt.value] }),
403
+ )}
404
+ >
405
+ <span>{opt.label}</span>
406
+ {dock && index < 9 && <Kbd>{index + 1}</Kbd>}
407
+ </button>
408
+ {opt.description && (
409
+ <p className="pl-0.5 text-[11.5px] leading-snug text-subtle">
410
+ {opt.description}
411
+ </p>
412
+ )}
413
+ </div>
414
+ ))}
415
+ </div>
416
+ )}
417
+ {/* #3282 §2: Delete sits apart at the bottom, styled destructive, with no digit
418
+ shortcut — the audited bug was that it looked and behaved just like Switch. */}
419
+ {destructiveAskOption && (
420
+ <div className="flex flex-wrap items-start gap-3">
421
+ <button
422
+ type="button"
423
+ className={DESTRUCTIVE_BUTTON}
424
+ onClick={onButton(() =>
425
+ answerAsk(prompt.id, { type: 'answer', values: [destructiveAskOption.value] }),
426
+ )}
427
+ >
428
+ <span>{destructiveAskOption.label}</span>
429
+ </button>
430
+ </div>
431
+ )}
432
+ {hasFreeText && (
433
+ <div className="flex flex-col gap-1.5">
434
+ {askOptions.length > 0 && (
435
+ <p className="text-[12.5px] text-subtle">or type an answer</p>
436
+ )}
437
+ <FreeTextField
438
+ // Remounts on every new prompt — no value typed for one question can ever appear
439
+ // in the DOM for another, masked or not, since this is a fresh component instance
440
+ // with its own fresh `useState('')`, not a value this component reset in an effect.
441
+ key={prompt.id}
442
+ ref={fieldHandleRef}
443
+ inputRef={fieldRef}
444
+ masked={prompt.request.masked === true}
445
+ placeholder={prompt.request.placeholder}
446
+ allowEmpty={prompt.request.allowEmpty === true}
447
+ wrapClick={onButton}
448
+ onSubmit={(value) =>
449
+ answerAsk(prompt.id, { type: 'answer', values: [], text: value })
450
+ }
451
+ onCancel={() => answerAsk(prompt.id, { type: 'cancelled' })}
452
+ onFocusChange={setFieldFocused}
453
+ />
454
+ </div>
455
+ )}
456
+ <div className="flex flex-wrap items-center gap-2">
457
+ <button
458
+ type="button"
459
+ className={GHOST_BUTTON}
460
+ onClick={onButton(() => answerAsk(prompt.id, { type: 'cancelled' }))}
461
+ >
462
+ Cancel
463
+ </button>
464
+ {dock && (
465
+ <KeyHint hasFocus={hasFocus} armed={armed} armedText={askArmedHint} />
466
+ )}
467
+ </div>
468
+ </div>
469
+ </>
470
+ )}
471
+ </div>
472
+ </div>
473
+ );
474
+ }
475
+
476
+ const BUTTON =
477
+ 'inline-flex items-center gap-2 rounded-lg px-3.5 py-1.5 text-[14px] font-medium transition-colors';
478
+ const PRIMARY_BUTTON = `${BUTTON} bg-primary text-primary-foreground hover:opacity-90`;
479
+ const SECONDARY_BUTTON = `${BUTTON} bg-raised text-foreground hover:bg-hover`;
480
+ const GHOST_BUTTON = `${BUTTON} text-muted-foreground hover:bg-hover hover:text-foreground`;
481
+ /** #3282 §2: a destructive ask option (Delete) — visually distinct from Switch/Edit/Test/Duplicate. */
482
+ const DESTRUCTIVE_BUTTON = `${BUTTON} bg-destructive/12 text-destructive hover:bg-destructive/20`;
483
+
484
+ /** Imperative escape hatch a parent can use to wipe the field's value without owning it. */
485
+ interface IFreeTextFieldHandle {
486
+ clear: () => void;
487
+ }
488
+
489
+ interface IFreeTextFieldProps {
490
+ masked: boolean;
491
+ placeholder?: string;
492
+ /** An empty answer is otherwise blocked, mirroring the runtime's own text-prompt renderers. */
493
+ allowEmpty: boolean;
494
+ /** The field's own DOM node, so the parent can move focus onto it once the prompt arms. */
495
+ inputRef: React.RefObject<HTMLInputElement | null>;
496
+ /** The parent's mouse-vs-keyboard-click gate (`armed`), applied to this field's own button. */
497
+ wrapClick: (answer: () => void) => (event: React.MouseEvent) => void;
498
+ onSubmit: (value: string) => void;
499
+ onCancel: () => void;
500
+ onFocusChange: (focused: boolean) => void;
501
+ }
502
+
503
+ /**
504
+ * The free-text answer to one ask prompt. Its value lives ONLY here, in a component the parent
505
+ * mounts with `key={prompt.id}` — so a cancelled or answered prompt's typed (possibly masked, e.g.
506
+ * an API key) text cannot survive into another prompt's render: there is no shared state to carry it,
507
+ * only a fresh `useState('')` on the next prompt's fresh instance. `clear` is exposed via `ref` as a
508
+ * second line of defense, so the parent can wipe the value at the moment ANY answer is given (Cancel,
509
+ * an option pick, the dock's Esc) and not only from the paths already local to this component (submit,
510
+ * Esc-in-field) — belt and suspenders, not a substitute for the remount.
511
+ */
512
+ const FreeTextField = forwardRef<IFreeTextFieldHandle, IFreeTextFieldProps>(function FreeTextField(
513
+ { masked, placeholder, allowEmpty, inputRef, wrapClick, onSubmit, onCancel, onFocusChange },
514
+ ref,
515
+ ) {
516
+ const [value, setValue] = useState('');
517
+ useImperativeHandle(ref, () => ({ clear: () => setValue('') }), []);
518
+ /** The typed value, trimmed as the runtime's own text-prompt renderers do; blocked while empty. */
519
+ const submit = (): void => {
520
+ const trimmed = value.trim();
521
+ if (!trimmed && !allowEmpty) return;
522
+ setValue('');
523
+ onSubmit(trimmed);
524
+ };
525
+ const cancel = (): void => {
526
+ setValue('');
527
+ onCancel();
528
+ };
529
+ /**
530
+ * A key that reaches the field types into it — the prompt's own shortcuts never see it (#3280 §3).
531
+ * The Enter that only finishes an IME composition (Korean/Japanese/Chinese) must not submit the
532
+ * still-uncommitted text — `isComposing` (or `keyCode` 229, on browsers that predate it) marks that
533
+ * Enter; the keystroke is left alone so the browser can commit the composition normally.
534
+ */
535
+ const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
536
+ event.stopPropagation();
537
+ if (event.key === 'Enter') {
538
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
539
+ event.preventDefault();
540
+ submit();
541
+ } else if (event.key === 'Escape') {
542
+ event.preventDefault();
543
+ cancel();
544
+ }
545
+ };
546
+ return (
547
+ <div className="flex items-center gap-2">
548
+ <input
549
+ ref={inputRef}
550
+ type={masked ? 'password' : 'text'}
551
+ autoComplete="off"
552
+ aria-label="answer"
553
+ placeholder={placeholder}
554
+ value={value}
555
+ onChange={(event) => setValue(event.target.value)}
556
+ onKeyDown={onKeyDown}
557
+ onFocus={() => onFocusChange(true)}
558
+ onBlur={() => onFocusChange(false)}
559
+ className="min-w-0 flex-1 rounded-lg bg-sidebar px-3 py-1.5 text-[14px] text-foreground placeholder:text-subtle focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
560
+ />
561
+ <button
562
+ type="button"
563
+ className={PRIMARY_BUTTON}
564
+ disabled={!value.trim() && !allowEmpty}
565
+ onClick={wrapClick(submit)}
566
+ >
567
+ Continue
568
+ </button>
569
+ </div>
570
+ );
571
+ });
572
+
573
+ function Kbd({ children }: { children: React.ReactNode }): React.ReactElement {
574
+ return (
575
+ // Hidden from the accessible name: the button is "Allow", and the hint line says which key answers.
576
+ <kbd
577
+ aria-hidden="true"
578
+ className="rounded bg-foreground/10 px-1 font-mono text-[11px] leading-[1.45] opacity-70"
579
+ >
580
+ {children}
581
+ </kbd>
582
+ );
583
+ }
584
+
585
+ function KeyHint({
586
+ hasFocus,
587
+ armed,
588
+ armedText,
589
+ }: {
590
+ hasFocus: boolean;
591
+ armed: boolean;
592
+ /** What the prompt's keys do once armed — differs by kind and by whether it offers free text. */
593
+ armedText: string;
594
+ }): React.ReactElement {
595
+ const text = !hasFocus
596
+ ? 'Click to answer, or press Shift+Tab to use the keys'
597
+ : armed
598
+ ? armedText
599
+ : 'keys answer in a moment · click to answer now';
600
+ return <p className="ml-auto text-[12.5px] text-subtle">{text}</p>;
601
+ }
602
+
603
+ /**
604
+ * Everything the tool was asked to do, since that is what the owner approves: a command line first, as it
605
+ * is, then every other argument (where it runs, what it is fed) as `key: value`. Nothing is cut; a long
606
+ * block scrolls.
607
+ */
608
+ function ToolArgs({ args }: { args: unknown }): React.ReactElement | null {
609
+ if (args === null || typeof args !== 'object') return null;
610
+ const entries = Object.entries(args as Record<string, unknown>);
611
+ if (entries.length === 0) return null;
612
+ const commandKey = entries.find(
613
+ ([key, value]) => (key === 'command' || key === 'cmd') && typeof value === 'string',
614
+ )?.[0];
615
+ const rest = entries.filter(([key]) => key !== commandKey);
616
+ const format = (value: unknown): string =>
617
+ typeof value === 'string' ? value : JSON.stringify(value, null, 2);
618
+ return (
619
+ <div className="mt-2.5 max-h-48 overflow-auto rounded-lg bg-sidebar px-3 py-2 font-mono text-[12.5px] leading-relaxed">
620
+ {commandKey ? (
621
+ <pre className="whitespace-pre-wrap break-all text-foreground">
622
+ {(args as Record<string, string>)[commandKey]}
623
+ </pre>
624
+ ) : null}
625
+ {rest.length > 0 ? (
626
+ <dl className={`text-muted-foreground ${commandKey ? 'mt-1.5' : ''}`}>
627
+ {rest.map(([key, value]) => (
628
+ <div key={key} className="flex gap-2">
629
+ <dt className="flex-shrink-0 text-subtle">{key}:</dt>
630
+ <dd className="min-w-0 whitespace-pre-wrap break-all">{format(value)}</dd>
631
+ </div>
632
+ ))}
633
+ </dl>
634
+ ) : null}
635
+ </div>
636
+ );
637
+ }
638
+
639
+ /**
640
+ * #3288: a Shell request shows just its command — and the working directory only when the server
641
+ * says it differs from the workspace — instead of every raw argument.
642
+ */
643
+ function ShellCommandPreview({
644
+ command,
645
+ cwd,
646
+ }: {
647
+ command: string;
648
+ cwd?: string;
649
+ }): React.ReactElement {
650
+ return (
651
+ <div className="mt-2.5 max-h-48 overflow-auto rounded-lg bg-sidebar px-3 py-2 font-mono text-[12.5px] leading-relaxed">
652
+ <pre className="whitespace-pre-wrap break-all text-foreground">{command}</pre>
653
+ {cwd && (
654
+ <p className="mt-1.5 text-muted-foreground">
655
+ <span className="text-subtle">in </span>
656
+ {cwd}
657
+ </p>
658
+ )}
659
+ </div>
660
+ );
661
+ }