@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,685 @@
1
+ import { useProductIdentity } from '../product-identity.js';
2
+ import { ArrowUp, Paperclip, Square, Target, X } from 'lucide-react';
3
+ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
4
+
5
+ import { commandMenuFor } from '../hooks/command-menu.js';
6
+ import type { ICommandMenuItem } from '../hooks/command-menu.js';
7
+ import {
8
+ buildPromptWithAttachments,
9
+ evaluateCandidateFile,
10
+ type ICandidateFile,
11
+ type IDraftAttachment,
12
+ type IPickedFile,
13
+ } from './composer-attachments.js';
14
+ import { StatusRow } from './StatusControls.js';
15
+
16
+ import type {
17
+ IQueuedPrompt,
18
+ TCommandCatalog,
19
+ TModelListSnapshot,
20
+ TSessionStatus,
21
+ } from '../hooks/session-client-types.js';
22
+
23
+ export type { IPickedFile } from './composer-attachments.js';
24
+
25
+ /** What a caller can do to the composer from outside it — currently just reclaiming focus. */
26
+ export interface IComposerHandle {
27
+ /** Focuses the message field — used to send focus back there once a docked prompt is answered. */
28
+ focus: () => void;
29
+ }
30
+
31
+ /** The draft as persisted: the typed text plus any attachment chips (#3282 §4d). */
32
+ interface IStoredDraft {
33
+ readonly text: string;
34
+ readonly attachments: readonly IDraftAttachment[];
35
+ }
36
+
37
+ const EMPTY_DRAFT: IStoredDraft = { text: '', attachments: [] };
38
+
39
+ /**
40
+ * #3282 §4e: a command whose whole job is opening a GUI screen — choosing it from the `/` menu runs
41
+ * it at once (its `ui_intent` opens the screen the normal way) instead of filling the draft and
42
+ * waiting for Enter, since there is nothing useful to type after it. `help` never reaches the
43
+ * session at all (`useSessionClient.ts`'s `send` opens the Help sheet locally). Every other command
44
+ * still "runs as today": chosen or Tab-completed, it fills the draft for its arguments.
45
+ */
46
+ const IMMEDIATE_SCREEN_COMMANDS: ReadonlySet<string> = new Set(['settings', 'resume', 'help']);
47
+
48
+ function isImmediateScreenCommand(item: ICommandMenuItem): boolean {
49
+ return item.kind === 'command' && IMMEDIATE_SCREEN_COMMANDS.has(item.name);
50
+ }
51
+
52
+ /** The group a menu row falls under — shown as a header above the first row of each. */
53
+ function menuGroupLabel(item: ICommandMenuItem): 'Commands' | 'Skills' {
54
+ return item.kind === 'skill' ? 'Skills' : 'Commands';
55
+ }
56
+
57
+ /**
58
+ * #3280 §4: the unsent draft survives a Chat → Usage → Chat switch (the composer unmounts), a page
59
+ * reload, and a desktop relaunch — `localStorage`, not `sessionStorage`, since only `localStorage`
60
+ * survives a closed window/tab being reopened. Namespaced (the configured draft prefix, matching
61
+ * the configured restore key in `use-session-directory.ts`) so a page hosting other state under the
62
+ * same origin does not collide. Per session id when one is known; a single fallback key before the
63
+ * first status arrives (the gap is brief and is reconciled once it does — see the effect below).
64
+ *
65
+ * #3282 §4d: the stored value is now JSON (`IStoredDraft`), not the bare text string it used to be —
66
+ * `parseStoredDraft` treats anything that does not parse as that shape (including a draft saved
67
+ * before this change shipped) as plain text with no attachments, so an old stored draft still loads.
68
+ */
69
+
70
+ function draftStorageKey(namespace: string, sessionId: string | undefined): string {
71
+ return sessionId ? `${namespace}.draft.${sessionId}` : `${namespace}.draft`;
72
+ }
73
+
74
+ /**
75
+ * A stored attachment's shape is never trusted blindly: it is `localStorage`, not this component's
76
+ * own state, so it can be edited by hand, left over from a future version with a different shape, or
77
+ * just corrupted. A missing/non-string `name` or `relativePath` would otherwise show a blank chip and
78
+ * send a broken (or empty) `@`-reference on submit — dropped instead of risking either.
79
+ */
80
+ function isStoredAttachment(value: unknown): value is IDraftAttachment {
81
+ if (!value || typeof value !== 'object') return false;
82
+ const candidate = value as Record<string, unknown>;
83
+ return (
84
+ typeof candidate.id === 'string' &&
85
+ typeof candidate.name === 'string' &&
86
+ typeof candidate.relativePath === 'string' &&
87
+ typeof candidate.size === 'number'
88
+ );
89
+ }
90
+
91
+ function parseStoredDraft(raw: string | null): IStoredDraft {
92
+ if (!raw) return EMPTY_DRAFT;
93
+ try {
94
+ const parsed: unknown = JSON.parse(raw);
95
+ if (parsed && typeof parsed === 'object' && typeof (parsed as { text?: unknown }).text === 'string') {
96
+ const attachments = (parsed as { attachments?: unknown }).attachments;
97
+ return {
98
+ text: (parsed as { text: string }).text,
99
+ attachments: Array.isArray(attachments) ? attachments.filter(isStoredAttachment) : [],
100
+ };
101
+ }
102
+ } catch {
103
+ // Not JSON — a draft saved before attachments shipped. Fall through to plain text below.
104
+ }
105
+ return { text: raw, attachments: [] };
106
+ }
107
+
108
+ /** Best-effort: a private window, cleared site data, or a full quota still leaves typing working. */
109
+ function readDraft(namespace: string, sessionId: string | undefined): IStoredDraft {
110
+ try {
111
+ return parseStoredDraft(window.localStorage.getItem(draftStorageKey(namespace, sessionId)));
112
+ } catch {
113
+ // allow-fallback: storage unavailable — the draft still lives in component state this session.
114
+ return EMPTY_DRAFT;
115
+ }
116
+ }
117
+
118
+ function writeDraft(namespace: string, sessionId: string | undefined, value: IStoredDraft): void {
119
+ try {
120
+ const key = draftStorageKey(namespace, sessionId);
121
+ if (!value.text && value.attachments.length === 0) window.localStorage.removeItem(key);
122
+ else window.localStorage.setItem(key, JSON.stringify(value));
123
+ } catch {
124
+ // allow-fallback: same as readDraft — typing (and sending) still work without persistence.
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The composer — the control centre, as desktop agent apps place it: the message box, a `/` menu of
130
+ * the commands and skills the session offers, and a status row (model, permission mode, effort,
131
+ * context). The row's controls run the session's own commands (`/provider`, `/mode`, `/effort`), so a
132
+ * setting changes through the one path every client shares.
133
+ */
134
+ export const Composer = forwardRef<
135
+ IComposerHandle,
136
+ {
137
+ onSubmit: (prompt: string) => void;
138
+ onCommand: (name: string, args?: string) => void;
139
+ catalog: TCommandCatalog | null;
140
+ status: TSessionStatus | null;
141
+ /**
142
+ * False while the transport is not `connected` (issue #3280 §5): Enter and Send refuse to submit,
143
+ * and nothing typed is cleared or lost — the composer never sends into a socket that is not there.
144
+ * #3282 §2 (part 2): the status chips and the `/` command menu are disabled the same way.
145
+ */
146
+ connected?: boolean;
147
+ /** #3280 §2: a turn (or a blocking command) is running — Send becomes Stop and Esc stops it too. */
148
+ running: boolean;
149
+ onStop: () => void;
150
+ /** #3280 §2: the prompt queued behind the running turn, or null when none is queued. */
151
+ queued: IQueuedPrompt | null;
152
+ onCancelQueue: () => void;
153
+ /**
154
+ * Opens the host's native multi-file dialog with real filesystem paths — present only on the
155
+ * desktop app (#3282 §4d). Its absence means the attach button falls back to a plain HTML file
156
+ * picker, whose picks a browser can never resolve to a path (rule 3: shown plainly, nothing
157
+ * attached).
158
+ */
159
+ pickFiles?: () => Promise<readonly IPickedFile[]>;
160
+ /**
161
+ * Resolves a dropped or picked `File` to its real filesystem path — present only on the desktop
162
+ * app, via Electron's `webUtils.getPathForFile` (#3282 §4d). Its absence means a drop can never
163
+ * become an `@`-reference either.
164
+ */
165
+ getPathForFile?: (file: File) => string;
166
+ /** #3282 §2 (part 2): the model control's pop-up menu — null until requested. */
167
+ modelList?: TModelListSnapshot | null;
168
+ onRequestModelList?: () => void;
169
+ /** Applies a model/mode/effort choice without a conversation card (the control's label confirms it). */
170
+ onSilentCommand?: (name: string, args?: string) => void;
171
+ /**
172
+ * #3282 §4b: "Manage providers…" opens Settings at the Providers & Models section. Defaults to
173
+ * dispatching the bare `/provider` command (the pre-#3282-§4b behavior) for a caller that has not
174
+ * wired Settings — every shipped caller passes its own `state.openSettings('providers')`.
175
+ */
176
+ onManageProviders?: () => void;
177
+ }
178
+ >(function Composer(
179
+ {
180
+ onSubmit,
181
+ onCommand,
182
+ catalog,
183
+ status,
184
+ connected = true,
185
+ running,
186
+ onStop,
187
+ queued,
188
+ onCancelQueue,
189
+ pickFiles,
190
+ getPathForFile,
191
+ modelList = null,
192
+ onRequestModelList = () => {},
193
+ onSilentCommand = onCommand,
194
+ onManageProviders = () => onCommand('provider'),
195
+ },
196
+ ref,
197
+ ): React.ReactElement {
198
+ const { identity, storage: { browserNamespace } } = useProductIdentity();
199
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
200
+ useImperativeHandle(ref, () => ({ focus: () => textareaRef.current?.focus() }), []);
201
+ const sessionId = status?.sessionId;
202
+ const sessionIdRef = useRef(sessionId);
203
+ // Whether a REAL session id has ever been seen. A switch goes A -> null -> B — `session_switched`
204
+ // clears `sessionStatus` before `get-status` answers (`useSessionClient.ts`) — so `sessionId` turns
205
+ // transiently `undefined` on an ordinary switch too, not only before the very first status. This
206
+ // ref is the one thing that distinguishes "no session has ever been known yet" (the fallback-key
207
+ // migration case) from "between two known sessions right now" — never overload `undefined` for it.
208
+ const hasKnownSessionRef = useRef(sessionId !== undefined);
209
+ // The draft (text + attachments) as last set, read inside effects/handlers without depending on
210
+ // component state and risking a stale closure — kept in lockstep with the `draft`/`attachments`
211
+ // state below by `setDraft`/`setAttachments`, the only two places that mutate it.
212
+ const stateRef = useRef<IStoredDraft>(EMPTY_DRAFT);
213
+ const [draft, setDraftState] = useState(() => {
214
+ const initial = readDraft(browserNamespace, sessionId);
215
+ stateRef.current = initial;
216
+ return initial.text;
217
+ });
218
+ const [attachments, setAttachmentsState] = useState<readonly IDraftAttachment[]>(
219
+ () => stateRef.current.attachments,
220
+ );
221
+ const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
222
+ const [selected, setSelected] = useState(0);
223
+ const [dismissed, setDismissed] = useState(false);
224
+ // #3282 §2 (part 2): the slash menu is disabled while disconnected — none of its commands could run.
225
+ const menu = dismissed || !connected ? null : commandMenuFor(catalog, draft);
226
+ useEffect(() => {
227
+ setSelected(0);
228
+ setDismissed(false);
229
+ }, [draft]);
230
+ // #3282 §4e: the menu can hold more rows than fit in its scroll area — keyboard navigation (below)
231
+ // must keep the highlighted row in view, not just move the highlight off-screen.
232
+ const menuOptionRefs = useRef<(HTMLButtonElement | null)[]>([]);
233
+ useEffect(() => {
234
+ menuOptionRefs.current[selected]?.scrollIntoView?.({ block: 'nearest' });
235
+ }, [selected, menu]);
236
+ /** #3280 §4: every draft change is persisted at once, so a reload or relaunch loses nothing. */
237
+ const setDraft = (value: string): void => {
238
+ stateRef.current = { ...stateRef.current, text: value };
239
+ setDraftState(value);
240
+ writeDraft(browserNamespace, sessionIdRef.current, stateRef.current);
241
+ };
242
+ /** #3282 §4d: attachment chips persist alongside the text, under the same per-session draft key. */
243
+ const setAttachments = (
244
+ updater: readonly IDraftAttachment[] | ((current: readonly IDraftAttachment[]) => readonly IDraftAttachment[]),
245
+ ): void => {
246
+ const next = typeof updater === 'function' ? updater(stateRef.current.attachments) : updater;
247
+ stateRef.current = { ...stateRef.current, attachments: next };
248
+ setAttachmentsState(next);
249
+ writeDraft(browserNamespace, sessionIdRef.current, stateRef.current);
250
+ };
251
+ // #3280 §4: a session switch shows THAT session's own saved draft, never what was typed for
252
+ // another one. The session id becoming known for the very FIRST time (the fallback key was in use
253
+ // until now) instead carries over what is already typed, rather than discarding it. A transient
254
+ // `undefined` mid-switch, once a real id has already been seen, is not a change at all: stay bound
255
+ // to the last known session — keep showing and writing to ITS draft — until a new CONCRETE id
256
+ // arrives, so keystrokes typed during the round trip never land under the wrong session (or the
257
+ // fallback key).
258
+ useEffect(() => {
259
+ if (sessionId === undefined && hasKnownSessionRef.current) return;
260
+ if (sessionIdRef.current === sessionId) return;
261
+ const firstArrival = !hasKnownSessionRef.current;
262
+ const previous = sessionIdRef.current;
263
+ sessionIdRef.current = sessionId;
264
+ if (sessionId !== undefined) hasKnownSessionRef.current = true;
265
+ const stored = readDraft(browserNamespace, sessionId);
266
+ const hasStored = stored.text !== '' || stored.attachments.length > 0;
267
+ const hasCarryOver = stateRef.current.text !== '' || stateRef.current.attachments.length > 0;
268
+ if (firstArrival && !hasStored && hasCarryOver) {
269
+ writeDraft(browserNamespace, sessionId, stateRef.current);
270
+ writeDraft(browserNamespace, previous, EMPTY_DRAFT);
271
+ return;
272
+ }
273
+ stateRef.current = stored;
274
+ setDraftState(stored.text);
275
+ setAttachmentsState(stored.attachments);
276
+ setAttachmentNotice(null); // a notice belongs to the attempt just made in the session left behind
277
+ }, [browserNamespace, sessionId]);
278
+
279
+ const submit = (): void => {
280
+ if (!connected) return;
281
+ const text = draft.trim();
282
+ if (!text && attachments.length === 0) return;
283
+ onSubmit(buildPromptWithAttachments(text, attachments));
284
+ setDraft('');
285
+ setAttachments([]);
286
+ setAttachmentNotice(null);
287
+ };
288
+ const workspacePath = status?.workspace?.path;
289
+ const totalAttachedBytes = attachments.reduce((sum, a) => sum + a.size, 0);
290
+ /** Evaluate every dropped/picked file in order, so a mixed batch attaches what it can. */
291
+ const addCandidates = (candidates: readonly ICandidateFile[]): void => {
292
+ if (candidates.length === 0) return;
293
+ let total = totalAttachedBytes;
294
+ let count = attachments.length;
295
+ const added: IDraftAttachment[] = [];
296
+ let notice: string | null = null;
297
+ for (const candidate of candidates) {
298
+ const outcome = evaluateCandidateFile(candidate, workspacePath, total, count);
299
+ if (outcome.kind === 'attached') {
300
+ added.push(outcome.attachment);
301
+ total += outcome.attachment.size;
302
+ count += 1;
303
+ } else {
304
+ notice = outcome.message;
305
+ }
306
+ }
307
+ if (added.length > 0) setAttachments((current) => [...current, ...added]);
308
+ setAttachmentNotice(notice);
309
+ };
310
+ const removeAttachment = (id: string): void => {
311
+ setAttachments((current) => current.filter((a) => a.id !== id));
312
+ };
313
+ const toCandidateFromFile = (file: File): ICandidateFile => ({
314
+ name: file.name,
315
+ size: file.size,
316
+ mimeType: file.type || undefined,
317
+ absolutePath: getPathForFile ? getPathForFile(file) || undefined : undefined,
318
+ });
319
+ const fileInputRef = useRef<HTMLInputElement>(null);
320
+ const handleAttachClick = (): void => {
321
+ if (!connected) return;
322
+ if (pickFiles) {
323
+ pickFiles()
324
+ .then((picked) =>
325
+ addCandidates(picked.map((f) => ({ name: f.name, size: f.size, absolutePath: f.path }))),
326
+ )
327
+ // The host's dialog IPC can reject (e.g. the window closed mid-pick) — say so rather than
328
+ // leaving an unhandled rejection and a button that silently did nothing (rule 5: never fail
329
+ // silently).
330
+ .catch(() => setAttachmentNotice('Could not open the file picker. Try again.'));
331
+ return;
332
+ }
333
+ fileInputRef.current?.click();
334
+ };
335
+ const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
336
+ const files = Array.from(event.target.files ?? []);
337
+ event.target.value = ''; // allow picking the same (rejected) file again after fixing it
338
+ addCandidates(files.map(toCandidateFromFile));
339
+ };
340
+ const dragDepthRef = useRef(0);
341
+ const [isDraggingFiles, setIsDraggingFiles] = useState(false);
342
+ const hasFilesDrag = (event: React.DragEvent): boolean =>
343
+ Array.from(event.dataTransfer?.types ?? []).includes('Files');
344
+ const onDragEnter = (event: React.DragEvent): void => {
345
+ if (!connected || !hasFilesDrag(event)) return;
346
+ event.preventDefault();
347
+ dragDepthRef.current += 1;
348
+ setIsDraggingFiles(true);
349
+ };
350
+ const onDragOver = (event: React.DragEvent): void => {
351
+ if (!connected || !hasFilesDrag(event)) return;
352
+ event.preventDefault(); // required for onDrop to fire
353
+ };
354
+ const onDragLeave = (): void => {
355
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
356
+ if (dragDepthRef.current === 0) setIsDraggingFiles(false);
357
+ };
358
+ const onDrop = (event: React.DragEvent): void => {
359
+ if (!connected || !hasFilesDrag(event)) return;
360
+ event.preventDefault();
361
+ dragDepthRef.current = 0;
362
+ setIsDraggingFiles(false);
363
+ addCandidates(Array.from(event.dataTransfer?.files ?? []).map(toCandidateFromFile));
364
+ };
365
+ /** Choosing a command from the menu, by mouse or by keyboard (`acceptMenu` below). */
366
+ const chooseMenuItem = (item: ICommandMenuItem): void => {
367
+ if (isImmediateScreenCommand(item)) {
368
+ setDraft('');
369
+ onCommand(item.name);
370
+ return;
371
+ }
372
+ setDraft(`/${item.name} `);
373
+ };
374
+ /** Complete the highlighted name; a draft that already names it is sent instead. A command whose
375
+ * whole job is opening a screen runs at once either way — see `IMMEDIATE_SCREEN_COMMANDS`. */
376
+ const acceptMenu = (): boolean => {
377
+ const item = menu?.[selected];
378
+ if (!item) return false;
379
+ if (!isImmediateScreenCommand(item) && draft === `/${item.name}`) return false;
380
+ chooseMenuItem(item);
381
+ return true;
382
+ };
383
+ /** #3280 §2: Edit puts the queued text back in the draft and cancels the queue behind it (the wire
384
+ * has no per-message cancel — only a whole-queue clear). Offered only when exactly one prompt is
385
+ * queued: with more than one, `cancel-queue` would still drop every one of them, but only the
386
+ * shown prompt's text is known here, so "Edit" would silently lose the rest — the row offers only
387
+ * "Remove all" instead once `queued.count > 1`. */
388
+ const editQueued = (): void => {
389
+ if (!queued) return;
390
+ setDraft(queued.text);
391
+ onCancelQueue();
392
+ };
393
+
394
+ return (
395
+ <div
396
+ className="relative flex-shrink-0"
397
+ onDragEnter={onDragEnter}
398
+ onDragOver={onDragOver}
399
+ onDragLeave={onDragLeave}
400
+ onDrop={onDrop}
401
+ >
402
+ {/* #3282 §4d rule 7: the drop target is announced even to someone who cannot drag a file —
403
+ the attach button beside the textarea is how they reach the same result. */}
404
+ <p id="composer-attach-hint" className="sr-only">
405
+ Drag files here, or use Attach files, to add them to your message.
406
+ </p>
407
+ {isDraggingFiles && (
408
+ <div
409
+ role="status"
410
+ aria-live="polite"
411
+ className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-[22px] border-2 border-dashed border-accent bg-accent/10 text-[14px] font-medium text-accent"
412
+ >
413
+ Drop to attach
414
+ </div>
415
+ )}
416
+ {queued && (
417
+ <div
418
+ role="status"
419
+ aria-label="queued prompt"
420
+ className="gui-rise mb-2 flex items-center gap-3 rounded-2xl bg-card px-4 py-2 text-[13px]"
421
+ >
422
+ <span className="min-w-0 flex-1 truncate text-muted-foreground">
423
+ Queued: {queued.text}
424
+ {queued.count > 1 && (
425
+ <span className="text-subtle"> and {queued.count - 1} more</span>
426
+ )}
427
+ </span>
428
+ {queued.count === 1 ? (
429
+ <>
430
+ <button
431
+ type="button"
432
+ onClick={editQueued}
433
+ className="flex-shrink-0 rounded-lg px-2 py-1 text-muted-foreground hover:bg-hover hover:text-foreground"
434
+ >
435
+ Edit
436
+ </button>
437
+ <button
438
+ type="button"
439
+ onClick={onCancelQueue}
440
+ className="flex-shrink-0 rounded-lg px-2 py-1 text-muted-foreground hover:bg-hover hover:text-foreground"
441
+ >
442
+ Remove
443
+ </button>
444
+ </>
445
+ ) : (
446
+ <button
447
+ type="button"
448
+ onClick={onCancelQueue}
449
+ className="flex-shrink-0 rounded-lg px-2 py-1 text-muted-foreground hover:bg-hover hover:text-foreground"
450
+ >
451
+ Remove all
452
+ </button>
453
+ )}
454
+ </div>
455
+ )}
456
+ {menu && (
457
+ <div
458
+ role="listbox"
459
+ aria-label="commands"
460
+ className="gui-rise absolute bottom-full left-0 right-0 mb-2 max-h-[320px] overflow-y-auto rounded-2xl bg-popover p-1.5 shadow-2xl shadow-black/35"
461
+ >
462
+ {menu.map((item, index) => {
463
+ // #3282 §4e: "Commands" then "Skills" — a header appears once, above the first row of
464
+ // its group (the menu always lists every command before every skill, so a group's rows
465
+ // are contiguous).
466
+ const showGroupHeader = index === 0 || menuGroupLabel(menu[index - 1]!) !== menuGroupLabel(item);
467
+ return (
468
+ <div key={`${item.kind}:${item.name}`}>
469
+ {showGroupHeader && (
470
+ <div
471
+ role="presentation"
472
+ className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-subtle first:pt-1"
473
+ >
474
+ {menuGroupLabel(item)}
475
+ </div>
476
+ )}
477
+ <button
478
+ ref={(el) => {
479
+ menuOptionRefs.current[index] = el;
480
+ }}
481
+ type="button"
482
+ role="option"
483
+ aria-selected={index === selected}
484
+ onMouseEnter={() => setSelected(index)}
485
+ onMouseDown={(event) => {
486
+ event.preventDefault();
487
+ chooseMenuItem(item);
488
+ }}
489
+ className={`flex w-full items-baseline gap-3 rounded-xl px-3 py-2 text-left text-[14px] ${
490
+ index === selected ? 'bg-hover' : ''
491
+ }`}
492
+ >
493
+ <span className="flex-shrink-0 font-mono text-[13.5px] text-foreground">
494
+ /{item.name}
495
+ </span>
496
+ <span className="min-w-0 flex-1 truncate text-muted-foreground">
497
+ {item.description}
498
+ </span>
499
+ </button>
500
+ </div>
501
+ );
502
+ })}
503
+ </div>
504
+ )}
505
+ <form
506
+ className="rounded-[22px] bg-card px-2.5 pb-2 pt-2.5 shadow-[0_8px_30px_-12px_rgb(0_0_0/0.45)] transition-shadow focus-within:ring-2 focus-within:ring-ring"
507
+ onSubmit={(e) => {
508
+ e.preventDefault();
509
+ submit();
510
+ }}
511
+ >
512
+ {attachments.length > 0 && (
513
+ <ul
514
+ aria-label="attachments"
515
+ className="mb-1.5 flex flex-wrap gap-1.5 px-1 pt-0.5"
516
+ >
517
+ {attachments.map((attachment) => (
518
+ <li
519
+ key={attachment.id}
520
+ title={attachment.relativePath}
521
+ className="flex max-w-full items-center gap-1.5 rounded-lg bg-raised px-2 py-1 text-[12.5px] text-muted-foreground"
522
+ >
523
+ <Paperclip size={12} strokeWidth={1.75} aria-hidden="true" className="flex-shrink-0" />
524
+ <span className="max-w-[180px] truncate">{attachment.name}</span>
525
+ <button
526
+ type="button"
527
+ aria-label={`Remove ${attachment.name}`}
528
+ onClick={() => removeAttachment(attachment.id)}
529
+ className="flex-shrink-0 rounded-full p-0.5 hover:bg-hover hover:text-foreground"
530
+ >
531
+ <X size={12} strokeWidth={2} aria-hidden="true" />
532
+ </button>
533
+ </li>
534
+ ))}
535
+ </ul>
536
+ )}
537
+ {attachmentNotice && (
538
+ <p role="status" aria-live="polite" className="mb-1.5 px-1.5 text-[12.5px] text-muted-foreground">
539
+ {attachmentNotice}
540
+ </p>
541
+ )}
542
+ <input
543
+ ref={fileInputRef}
544
+ type="file"
545
+ multiple
546
+ onChange={handleFileInputChange}
547
+ className="sr-only"
548
+ tabIndex={-1}
549
+ aria-hidden="true"
550
+ />
551
+ <textarea
552
+ ref={textareaRef}
553
+ aria-label="message"
554
+ aria-describedby="composer-attach-hint"
555
+ rows={1}
556
+ value={draft}
557
+ onChange={(e) => setDraft(e.target.value)}
558
+ onKeyDown={(e) => {
559
+ // #3280 §4: an Enter that only finishes an IME (Korean/Japanese/Chinese) composition must
560
+ // not submit or accept the menu — `isComposing` (or `keyCode` 229, on browsers that
561
+ // predate it) marks it; the keystroke is left alone so the browser commits the composition
562
+ // normally, matching the guard the free-text prompt field uses (#3280 §3).
563
+ if (e.key === 'Enter' && (e.nativeEvent.isComposing || e.keyCode === 229)) return;
564
+ if (menu) {
565
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
566
+ e.preventDefault();
567
+ const step = e.key === 'ArrowDown' ? 1 : -1;
568
+ setSelected((index) => (index + step + menu.length) % menu.length);
569
+ return;
570
+ }
571
+ if (e.key === 'Escape') {
572
+ e.preventDefault();
573
+ setDismissed(true);
574
+ return;
575
+ }
576
+ if ((e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) && acceptMenu()) {
577
+ e.preventDefault();
578
+ return;
579
+ }
580
+ }
581
+ // #3280 §2: Esc stops a running turn — but only once the menu (handled above) is out of
582
+ // the way, so dismissing the `/` menu never doubles as an abort. Not while disconnected:
583
+ // an abort could not reach the host either (issue #3280 §5).
584
+ if (e.key === 'Escape' && running && connected) {
585
+ e.preventDefault();
586
+ onStop();
587
+ return;
588
+ }
589
+ if (e.key === 'Enter' && !e.shiftKey) {
590
+ e.preventDefault();
591
+ submit();
592
+ }
593
+ }}
594
+ placeholder={`Ask ${identity.displayName} anything — type / for commands`}
595
+ className="block max-h-[220px] min-h-[48px] w-full resize-none bg-transparent px-2 py-1 text-[15px] leading-relaxed text-foreground [field-sizing:content] focus:outline-none"
596
+ />
597
+ <div className="mt-1 flex items-center gap-1">
598
+ <button
599
+ type="button"
600
+ aria-label="Attach files"
601
+ // #3282 §2 (part 2): the attach button follows the same disconnected rule as the status
602
+ // chips and the `/` menu — a short "Reconnecting…" tooltip in place of its usual one.
603
+ title={connected ? 'Attach files' : 'Reconnecting…'}
604
+ disabled={!connected}
605
+ onClick={handleAttachClick}
606
+ className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-hover hover:text-foreground disabled:text-subtle disabled:hover:bg-transparent"
607
+ >
608
+ <Paperclip size={16} strokeWidth={1.75} aria-hidden="true" />
609
+ </button>
610
+ <StatusRow
611
+ status={status}
612
+ catalog={catalog}
613
+ modelList={modelList}
614
+ onRequestModelList={onRequestModelList}
615
+ onManageProviders={onManageProviders}
616
+ onSilentCommand={onSilentCommand}
617
+ connected={connected}
618
+ />
619
+ <button
620
+ type={running ? 'button' : 'submit'}
621
+ onClick={running ? onStop : undefined}
622
+ // Stop is unavailable while disconnected too: an abort could not reach the host either.
623
+ disabled={!connected || (!running && !draft.trim() && attachments.length === 0)}
624
+ aria-description={connected ? undefined : 'Not connected'}
625
+ className="ml-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-all hover:opacity-85 disabled:bg-raised disabled:text-subtle"
626
+ >
627
+ {running ? (
628
+ <Square size={13} strokeWidth={2} fill="currentColor" aria-hidden="true" />
629
+ ) : (
630
+ <ArrowUp size={17} strokeWidth={2.25} aria-hidden="true" />
631
+ )}
632
+ <span className="sr-only">{running ? 'Stop' : 'Send'}</span>
633
+ </button>
634
+ </div>
635
+ </form>
636
+ </div>
637
+ );
638
+ });
639
+
640
+ /**
641
+ * The goal being pursued (`/goal`), above the composer while it is active — the objective, how far
642
+ * the turn budget has gone, and a way to stop it (`/goal cancel`).
643
+ */
644
+ export function GoalBar({
645
+ status,
646
+ onStop,
647
+ }: {
648
+ status: TSessionStatus | null;
649
+ onStop: () => void;
650
+ }): React.ReactElement | null {
651
+ const goal = status?.goal;
652
+ if (!goal || goal.status !== 'active') return null;
653
+ const progress = goal.maxIterations > 0 ? Math.min(1, goal.iterations / goal.maxIterations) : 0;
654
+ return (
655
+ <div
656
+ role="status"
657
+ aria-label="goal"
658
+ className="gui-rise relative flex flex-shrink-0 items-center gap-3 overflow-hidden rounded-2xl bg-card px-4 py-2.5 text-[14px]"
659
+ >
660
+ <Target size={16} strokeWidth={1.75} className="flex-shrink-0 text-accent" />
661
+ <span className="flex-shrink-0 font-medium text-foreground">Goal</span>
662
+ <span className="min-w-0 flex-1 truncate text-muted-foreground">{goal.objective}</span>
663
+ <span className="flex-shrink-0 text-[13px] tabular-nums text-subtle">
664
+ {goal.iterations}/{goal.maxIterations}
665
+ </span>
666
+ <button
667
+ type="button"
668
+ aria-label="Stop goal"
669
+ onClick={onStop}
670
+ className="flex flex-shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-[13px] text-muted-foreground hover:bg-hover hover:text-foreground"
671
+ >
672
+ <Square size={11} fill="currentColor" aria-hidden="true" />
673
+ Stop
674
+ </button>
675
+ <span
676
+ aria-hidden="true"
677
+ className="absolute bottom-0 left-0 h-[2px] bg-accent/70 transition-[width]"
678
+ style={{ width: `${progress * 100}%` }}
679
+ />
680
+ </div>
681
+ );
682
+ }
683
+
684
+ // `StatusRow` (model/mode/effort pop-up menus) and its `ContextRing` moved to `StatusControls.tsx`
685
+ // (#3282 §2 part 2) — imported above.