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

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