@opengeni/react 0.13.0 → 0.20.0

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 (113) hide show
  1. package/README.md +129 -23
  2. package/dist/chunk-6BXBGJ3B.js +1956 -0
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +642 -1080
  21. package/dist/index.js +7672 -8005
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +25 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +23 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +50 -1
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +104 -751
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +82 -16
  51. package/src/components/machine-health-pill.tsx +68 -0
  52. package/src/components/machine-metrics.tsx +10 -24
  53. package/src/components/machines/health.ts +146 -0
  54. package/src/components/machines/machine-detail.tsx +220 -0
  55. package/src/components/machines/metric-history-chart.tsx +298 -0
  56. package/src/components/machines/metric-sparkline.tsx +76 -0
  57. package/src/components/machines/series.ts +113 -0
  58. package/src/components/machines-dashboard.tsx +16 -4
  59. package/src/components/message-timeline.tsx +15 -7
  60. package/src/components/model-picker.tsx +12 -3
  61. package/src/components/pierre-diff.tsx +32 -6
  62. package/src/components/queue-surface-implementation.tsx +1041 -0
  63. package/src/components/queue-surface-state.tsx +94 -0
  64. package/src/components/queue-surface.tsx +60 -0
  65. package/src/components/sandbox-files.tsx +230 -21
  66. package/src/components/sandbox-terminal.tsx +8 -2
  67. package/src/components/sandbox-workspace.tsx +489 -135
  68. package/src/components/session-status.tsx +0 -6
  69. package/src/components/workbench-changes.tsx +145 -50
  70. package/src/components/workspace-dock.tsx +329 -68
  71. package/src/composer.ts +60 -0
  72. package/src/hooks/internal.ts +115 -34
  73. package/src/hooks/use-composer.ts +635 -74
  74. package/src/hooks/use-human-input.ts +131 -0
  75. package/src/hooks/use-machine-chip.ts +2 -2
  76. package/src/hooks/use-machines.ts +27 -11
  77. package/src/hooks/use-sandbox-files.ts +310 -62
  78. package/src/hooks/use-sandbox-git.ts +154 -40
  79. package/src/hooks/use-sandbox-terminal.ts +28 -6
  80. package/src/hooks/use-session-capabilities.ts +38 -9
  81. package/src/hooks/use-session-control.ts +43 -15
  82. package/src/hooks/use-session-events.ts +523 -48
  83. package/src/hooks/use-session-lineage.ts +15 -6
  84. package/src/hooks/use-session.ts +10 -2
  85. package/src/hooks/use-slash-commands.ts +38 -38
  86. package/src/hooks/use-transcription.ts +757 -0
  87. package/src/hooks/use-turn-queue.ts +273 -77
  88. package/src/hooks/use-windowed-sections.ts +2 -2
  89. package/src/hooks/use-workspace-capture.ts +146 -40
  90. package/src/hooks/use-workspace-edit.ts +50 -14
  91. package/src/hooks/use-workspace-sessions.ts +3 -0
  92. package/src/human-input.ts +72 -0
  93. package/src/index.ts +88 -12
  94. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  95. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  96. package/src/lib/use-portal-token-style.ts +51 -0
  97. package/src/lib/use-unicode-fonts.ts +57 -0
  98. package/src/machines.ts +16 -0
  99. package/src/provider.tsx +163 -41
  100. package/src/session-context.ts +135 -0
  101. package/src/session.ts +79 -0
  102. package/src/timeline/parsers.ts +78 -6
  103. package/src/timeline/projection.ts +36 -6
  104. package/src/timeline/tool-renderers.tsx +41 -0
  105. package/src/timeline/turn-summary.tsx +2 -2
  106. package/src/timeline/types.ts +16 -8
  107. package/styles/index.css +78 -0
  108. package/styles/index.d.ts +2 -0
  109. package/styles/tokens.css +11 -6
  110. package/styles/tokens.d.ts +2 -0
  111. package/dist/chunk-NFYVQWIB.js +0 -1377
  112. package/dist/chunk-NFYVQWIB.js.map +0 -1
  113. package/dist/machines-CnlMb7E-.d.ts +0 -329
@@ -1,127 +1,95 @@
1
- import type { ClientModel, SessionStatus } from "@opengeni/sdk";
2
- import {
3
- ArrowUpIcon,
4
- FileIcon,
5
- ImageIcon,
6
- LoaderCircleIcon,
7
- PaperclipIcon,
8
- PlayIcon,
9
- RotateCwIcon,
10
- SquareIcon,
11
- XIcon,
12
- } from "lucide-react";
13
- import { AnimatePresence, motion } from "motion/react";
14
- import {
15
- useCallback,
16
- useEffect,
17
- useId,
18
- useMemo,
19
- useRef,
20
- useState,
21
- type ChangeEvent,
22
- type ClipboardEvent,
23
- type DragEvent,
24
- type KeyboardEvent,
25
- type ReactNode,
26
- type RefObject,
27
- } from "react";
28
- import { argHint } from "../commands/registry";
29
- import type { Notice, SlashCommand } from "../commands/types";
1
+ import type { ClientModel, EffectiveSessionControl } from "@opengeni/sdk";
2
+ import type { ClipboardEvent, ReactNode } from "react";
3
+ import type { SlashCommand } from "../commands/types";
30
4
  import type { ComposerState } from "../hooks/use-composer";
31
- import { composerModeForKey, shouldSubmitOnKey } from "../hooks/use-composer";
32
5
  import type { UseFileAttachmentsResult } from "../hooks/use-file-attachments";
33
- import { defaultCommands } from "../commands/registry";
6
+ import type { SlashCommandContext } from "../hooks/use-slash-commands";
34
7
  import {
35
- useSlashCommands,
36
- type ConfirmState,
37
- type SlashCommandContext,
38
- } from "../hooks/use-slash-commands";
39
- import { cn } from "../lib/cn";
40
- import { formatBytes } from "../lib/format";
41
- import { CommandPalette } from "./command-palette";
42
- import { ModelPicker } from "./model-picker";
8
+ Actions,
9
+ AttachButton,
10
+ Attachments,
11
+ CommandPalette,
12
+ Confirmation,
13
+ Controls,
14
+ Footer,
15
+ Frame,
16
+ Help,
17
+ Hint,
18
+ Input,
19
+ ModelPicker,
20
+ OPEN_WORKSTREAM_CONTROL_EVENT,
21
+ PauseButton,
22
+ PausedState,
23
+ RestoredResources,
24
+ Root,
25
+ SendButton,
26
+ Status,
27
+ Surface,
28
+ useChatComposerController,
29
+ type ChatComposerMessages,
30
+ type ComposerControlLinks,
31
+ } from "./composer";
32
+ import {
33
+ ComposerTranscriptionControl,
34
+ type ComposerTranscriptionControlProps,
35
+ } from "./composer-transcription-control";
36
+
37
+ export { OPEN_WORKSTREAM_CONTROL_EVENT };
43
38
 
44
39
  export type ChatComposerProps = {
45
40
  composer: ComposerState;
46
- /** Current session status; shows the stop control while a turn runs. */
47
- status?: SessionStatus | null | undefined;
41
+ /** Canonical workstream control, separate from lifecycle status. */
42
+ effectiveControl?: EffectiveSessionControl | null | undefined;
43
+ /** Waiting prompts already ahead of a normal Send. */
44
+ queuedAheadCount?: number | undefined;
45
+ /** Whether broader Workspace Resume is authorized for this viewer. */
46
+ canControlWorkspace?: boolean | undefined;
47
+ /** Optional host routes used to navigate from effective Pause blockers. */
48
+ controlLinks?: ComposerControlLinks | undefined;
48
49
  placeholder?: string | undefined;
49
50
  disabled?: boolean | undefined;
50
51
  autoFocus?: boolean | undefined;
51
52
  /** Replaces the default keyboard hint under the field. */
52
53
  hint?: string | undefined;
53
- /** App controls (model picker, attach button, ...) in the footer row, replacing the hint. */
54
+ /** App controls in the footer row, replacing the hint. */
54
55
  controlsStart?: ReactNode | undefined;
55
- /** Content rendered above the textarea, inside the field chrome (e.g. attachment chips). */
56
+ /** Provider-neutral speech capability. Provider configuration stays in workspace settings. */
57
+ transcription?: ComposerTranscriptionControlProps | undefined;
58
+ /** Content rendered above the textarea, inside the field chrome. */
56
59
  header?: ReactNode | undefined;
57
- /** Paste hook on the textarea (e.g. paste-image-to-attach). */
60
+ /** Paste hook composed with the attachment paste path. */
58
61
  onPaste?: ((event: ClipboardEvent<HTMLTextAreaElement>) => void) | undefined;
59
- /**
60
- * Opt-in file attachments. When supplied (e.g. from {@link useFileAttachments}),
61
- * the composer renders a built-in attach button (prepended to `controlsStart`),
62
- * an attachment-chips strip (above the textarea, before any host `header`),
63
- * routes paste through `addFromPaste` (image/* filter lives in the hook), and
64
- * gates send while `uploading` so a message never departs without its files.
65
- * Absent → no attachment UI renders and the composer behaves exactly as before.
66
- */
62
+ /** Opt-in file attachment state, typically from `useFileAttachments`. */
67
63
  attachments?: UseFileAttachmentsResult | undefined;
68
- /**
69
- * Opt-in model picker. When supplied (e.g. from {@link useAvailableModels}),
70
- * the composer renders a {@link ModelPicker} at the start of `controlsStart`
71
- * so the operator can choose which host-exposed model serves the next message.
72
- * The host owns the selection (`selectedModel`/`onSelectModel`) and is
73
- * responsible for threading it into the composer's `sendExtras` (typically
74
- * `useComposer({ sendExtras: () => ({ model }) })`) so `composeSendInput`
75
- * carries it. Absent → no picker renders and the composer behaves as before.
76
- */
64
+ /** Opt-in model picker choices. */
77
65
  models?: ClientModel[] | undefined;
78
- /** The currently selected model id (the picker's controlled value). */
79
66
  selectedModel?: string | undefined;
80
- /** Called with the chosen model id when the operator picks one. */
81
67
  onSelectModel?: ((modelId: string) => void) | undefined;
82
68
  className?: string | undefined;
83
- /**
84
- * Slash-command palette. Defaults to the built-in {@link defaultCommands};
85
- * apps concat their own. Backward-compatible: when `commandContext` is absent
86
- * the palette is inert and behavior is identical to before.
87
- */
88
69
  commands?: readonly SlashCommand[] | undefined;
89
- /**
90
- * Wiring the palette needs to run server commands and gate visibility. The
91
- * composer supplies notice/openHelp/clearView/confirm internally.
92
- */
93
70
  commandContext?: SlashCommandContext | undefined;
94
- /** Reset the local timeline view (the /clear-view command target). */
95
71
  onClearView?: (() => void) | undefined;
72
+ /** Partial overrides for all composer-owned visible and accessible copy. */
73
+ messages?: Partial<ChatComposerMessages> | undefined;
96
74
  };
97
75
 
98
- const ACTIVE_STATUSES: ReadonlySet<SessionStatus> = new Set([
99
- "queued",
100
- "running",
101
- "requires_action",
102
- "recovering",
103
- "waiting_capacity",
104
- ]);
105
-
106
76
  /**
107
- * The chat composer the only human-to-agent input surface. Plain chat in,
108
- * everything else is the agent's job. Enter sends, Shift+Enter breaks the
109
- * line, and the stop control appears while a turn is running (sending while
110
- * running is legitimate steering, so send stays available too).
111
- *
112
- * Typing a leading "/" opens the slash-command palette — SESSION/OPERATOR
113
- * controls (clear, compact, pause goal, help), never a structured channel to
114
- * the agent. The palette is purely additive: with no `commandContext` it is
115
- * inert and the composer behaves exactly as before.
77
+ * Batteries-included chat composer. This preset is assembled exclusively from
78
+ * the public controller and compound primitives exported by the composer
79
+ * subpath, so custom and default layouts share one behavioral implementation.
116
80
  */
117
81
  export function ChatComposer({
118
82
  composer,
119
- status,
83
+ effectiveControl,
84
+ queuedAheadCount,
85
+ canControlWorkspace,
86
+ controlLinks,
120
87
  placeholder,
121
88
  disabled,
122
89
  autoFocus,
123
90
  hint,
124
91
  controlsStart,
92
+ transcription,
125
93
  header,
126
94
  onPaste,
127
95
  attachments,
@@ -129,680 +97,65 @@ export function ChatComposer({
129
97
  selectedModel,
130
98
  onSelectModel,
131
99
  className,
132
- commands = defaultCommands,
100
+ commands,
133
101
  commandContext,
134
102
  onClearView,
103
+ messages,
135
104
  }: ChatComposerProps) {
136
- const textareaRef = useRef<HTMLTextAreaElement | null>(null);
137
- const fileInputRef = useRef<HTMLInputElement | null>(null);
138
- const active = status != null && ACTIVE_STATUSES.has(status);
139
-
140
- // Block sends while attachments are still uploading so a message never
141
- // departs without the files the user attached to it. This gates BOTH the
142
- // Enter-to-send path (which calls composer.send directly, bypassing canSend)
143
- // and the send button — dropping either path could ship a fileless message.
144
- const blockedByUpload = attachments?.uploading === true;
145
-
146
- // A ready attachment makes a file-only message (empty draft) sendable. The
147
- // composer (when wired with `sendExtras.resources`) already reflects this in
148
- // `canSend`; we OR it in here too so send-enablement is correct even for a
149
- // composer whose canSend doesn't know about attachments — and so this stays
150
- // the single home of the attachment send-gate.
151
- const hasReadyAttachment = (attachments?.readyResources.length ?? 0) > 0;
152
- // The send affordance: text OR a ready attachment, never mid-upload or mid-send.
153
- const canSend = (composer.canSend || hasReadyAttachment) && !blockedByUpload && !composer.sending;
154
-
155
- // Drag-and-drop file attach: only a drop target when `attachments` is wired,
156
- // and only reacts to drags that actually carry files (so it never hijacks
157
- // normal text drag/drop). `dragging` drives the drop overlay.
158
- const [dragging, setDragging] = useState(false);
159
- const dragCarriesFiles = (event: { dataTransfer: DataTransfer | null }): boolean =>
160
- event.dataTransfer != null && [...event.dataTransfer.types].includes("Files");
161
- const handleDragOver = useCallback(
162
- (event: DragEvent<HTMLDivElement>) => {
163
- if (!attachments || !dragCarriesFiles(event)) {
164
- return;
165
- }
166
- // preventDefault marks this a valid drop target so the browser fires drop.
167
- event.preventDefault();
168
- setDragging(true);
169
- },
170
- [attachments],
171
- );
172
- const handleDragLeave = useCallback(
173
- (event: DragEvent<HTMLDivElement>) => {
174
- if (!attachments) {
175
- return;
176
- }
177
- // Ignore leaves bubbling from children: only clear when the pointer left
178
- // the composer bounds entirely (the related target is outside it).
179
- if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
180
- return;
181
- }
182
- setDragging(false);
183
- },
184
- [attachments],
185
- );
186
- const handleDrop = useCallback(
187
- (event: DragEvent<HTMLDivElement>) => {
188
- if (!attachments || !dragCarriesFiles(event)) {
189
- return;
190
- }
191
- event.preventDefault();
192
- setDragging(false);
193
- // Same path the picker uses: addFiles accepts ALL files (no image filter).
194
- if (event.dataTransfer.files.length > 0) {
195
- attachments.addFiles(event.dataTransfer.files);
196
- }
197
- },
198
- [attachments],
199
- );
200
-
201
- const [notice, setNotice] = useState<Notice | null>(null);
202
- const [helpOpen, setHelpOpen] = useState(false);
203
- const [confirmState, setConfirmState] = useState<ConfirmState>(null);
204
- const listboxId = useId();
205
-
206
- const resize = useCallback(() => {
207
- const textarea = textareaRef.current;
208
- if (!textarea) {
209
- return;
210
- }
211
- textarea.style.height = "0px";
212
- textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`;
213
- }, []);
214
-
215
- useEffect(() => {
216
- resize();
217
- }, [composer.value, resize]);
218
-
219
- // The UI affordances commands reach: surface a notice, open the help panel,
220
- // reset the local view, and the danger confirm flow (resolves when the
221
- // operator confirms/cancels in the confirm bar).
222
- const handlers = useMemo(
223
- () => ({
224
- notice: (next: Notice) => {
225
- setNotice(next);
226
- composer.clearError();
227
- },
228
- openHelp: () => setHelpOpen(true),
229
- // Report whether a view-reset was actually wired by the host: with no
230
- // onClearView the command is a no-op and must say so (not a false success).
231
- clearView: () => {
232
- if (!onClearView) {
233
- return false;
234
- }
235
- onClearView();
236
- return true;
237
- },
238
- // The hook binds the command actually being run into confirm() (see
239
- // use-slash-commands buildContext), so the confirm bar renders from THAT
240
- // command — never a near-match highlighted in the palette. This is what
241
- // keeps the destructive /clear from being mislabeled as /clear-view.
242
- confirm: (command: SlashCommand) =>
243
- new Promise<boolean>((resolve) => {
244
- setConfirmState({
245
- command,
246
- resolve: (confirmed) => {
247
- setConfirmState(null);
248
- resolve(confirmed);
249
- },
250
- });
251
- }),
252
- }),
253
- [composer, onClearView],
254
- );
255
-
256
- const palette = useSlashCommands({
105
+ const controller = useChatComposerController({
106
+ delivery: composer,
107
+ draft: composer,
108
+ control: composer,
109
+ effectiveControl,
110
+ queuedAheadCount,
111
+ canControlWorkspace,
112
+ controlLinks,
113
+ disabled,
114
+ attachments,
257
115
  commands,
258
- context: commandContext,
259
- handlers,
260
- value: composer.value,
261
- setValue: composer.setValue,
116
+ commandContext,
117
+ onClearView,
118
+ onPaste,
119
+ messages,
262
120
  });
263
-
264
- // The confirm bar renders from the command the hook is actually running —
265
- // carried into confirmState by handlers.confirm — NOT a near-match that
266
- // happens to be highlighted/active in the palette. (Re-deriving it from
267
- // palette.items[palette.highlight] mislabeled the destructive /clear as the
268
- // harmless /clear-view, since clear-view prefix-matches "clear" and sorts
269
- // first.)
270
- const pendingDangerCommand = confirmState ? confirmState.command : null;
271
-
272
- const paletteEnabled = commandContext !== undefined;
273
-
274
- // A slash-command draft must never be delivered to the agent as chat — it is
275
- // an operator control, not a message. The palette consumes Enter while open,
276
- // but after Escape the popover is closed yet the draft still matches a command;
277
- // block the send path (here and on the send button) so "/clear" can't be sent.
278
- const commandDraftBlocked = paletteEnabled && palette.isCommandDraft;
279
-
280
- const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
281
- // Palette key handling runs FIRST and only when the palette is open; when
282
- // closed it returns false and the existing send path is untouched.
283
- if (paletteEnabled && palette.onKeyDown(event)) {
284
- return;
285
- }
286
- if (shouldSubmitOnKey(event)) {
287
- event.preventDefault();
288
- if (commandDraftBlocked) {
289
- // Dismissed palette + a "/command" draft: don't send it as chat. Nudge
290
- // the operator to re-open the palette (any edit re-opens it) or clear it.
291
- setNotice({
292
- tone: "error",
293
- message:
294
- "That's a slash command — press Enter in the command list to run it, or edit the line to send a message.",
295
- });
296
- return;
297
- }
298
- if (blockedByUpload) {
299
- // Files are still uploading: swallow the Enter so the message can't
300
- // depart without them. The send button is disabled in the same state.
301
- return;
302
- }
303
- void composer.send(undefined, composerModeForKey(event));
304
- }
305
- };
306
-
307
- // The host's onPaste still fires (model/tool/whatever paste handling); when
308
- // attachments are wired, also feed the clipboard through addFromPaste so the
309
- // image/* filter (owned by the hook) attaches pasted images.
310
- const handlePaste = useCallback(
311
- (event: ClipboardEvent<HTMLTextAreaElement>) => {
312
- onPaste?.(event);
313
- attachments?.addFromPaste(event);
314
- },
315
- [onPaste, attachments],
316
- );
317
-
318
- const handleFileChange = useCallback(
319
- (event: ChangeEvent<HTMLInputElement>) => {
320
- if (event.target.files) {
321
- attachments?.addFiles(event.target.files);
322
- }
323
- event.target.value = "";
324
- },
325
- [attachments],
326
- );
327
-
328
- const helpCommands = useMemo(
329
- () =>
330
- commands.filter((command) => {
331
- if (command.permission && commandContext) {
332
- const perms = commandContext.permissions;
333
- return perms.includes(command.permission) || perms.includes("workspace:admin");
334
- }
335
- return true;
336
- }),
337
- [commands, commandContext],
338
- );
339
-
340
- const activeNotice =
341
- notice ??
342
- (composer.error
343
- ? {
344
- tone: "error" as const,
345
- message:
346
- composer.error.message || "Sending failed — your draft is still here. Try again.",
347
- }
348
- : null);
121
+ const hasControls = Boolean(attachments || models || controlsStart || transcription);
349
122
 
350
123
  return (
351
- // Respect the iOS home-indicator inset so the sticky composer never sits
352
- // under it (0 on non-notch devices and desktop, so it's inert there).
353
- <div
354
- className={cn("og-root", className)}
355
- style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
356
- >
357
- <div className="relative">
358
- {paletteEnabled ? (
359
- <CommandPalette
360
- open={palette.open && confirmState === null}
361
- items={palette.items}
362
- highlight={palette.highlight}
363
- onHighlight={palette.setHighlight}
364
- onRun={(index) => {
365
- // Run the CLICKED row directly. We must not route a pointer click
366
- // through runHighlighted: its exact-match override would re-resolve
367
- // "/clear" to the destructive clear even when the operator clicked
368
- // the harmless clear-view row. runAt honors the explicit selection.
369
- palette.setHighlight(index);
370
- void palette.runAt(index);
371
- }}
372
- argHintText={palette.activeArgHint}
373
- listboxId={listboxId}
374
- />
375
- ) : null}
376
- <div
377
- // Drag-and-drop file attach lives on the field wrapper, but only when
378
- // `attachments` is wired — without it the composer is not a drop target
379
- // and behaves exactly as before.
380
- onDragOver={attachments ? handleDragOver : undefined}
381
- onDragLeave={attachments ? handleDragLeave : undefined}
382
- onDrop={attachments ? handleDrop : undefined}
383
- className={cn(
384
- "relative rounded-og-lg border border-og-border bg-og-surface-1 shadow-og-sm",
385
- "transition-[border-color,box-shadow] duration-200",
386
- "focus-within:border-og-accent/60 focus-within:shadow-og-glow",
387
- // While files are dragged over, swap to a dashed accent border to
388
- // signal a live drop target (the overlay carries the label).
389
- dragging && "border-dashed border-og-accent",
390
- )}
391
- >
392
- {dragging ? (
393
- <div
394
- aria-hidden
395
- className={cn(
396
- "pointer-events-none absolute inset-0 z-10 flex items-center justify-center",
397
- "rounded-og-lg bg-og-surface-1/85 text-sm font-medium text-og-accent backdrop-blur-[1px]",
398
- )}
399
- >
400
- <span className="inline-flex items-center gap-2">
401
- <PaperclipIcon className="size-4" />
402
- Drop files to attach
403
- </span>
404
- </div>
405
- ) : null}
406
- {attachments && attachments.attachments.length > 0 ? (
407
- <AttachmentChips
408
- attachments={attachments.attachments}
409
- onRemove={attachments.remove}
410
- onRetry={attachments.retry}
411
- />
412
- ) : null}
124
+ <Root controller={controller} className={className}>
125
+ <Frame>
126
+ <CommandPalette />
127
+ <Surface>
128
+ <PausedState />
129
+ <RestoredResources />
130
+ <Attachments />
413
131
  {header}
414
- <textarea
415
- ref={textareaRef}
416
- rows={1}
417
- value={composer.value}
418
- onChange={(event) => composer.setValue(event.target.value)}
419
- onKeyDown={onKeyDown}
420
- onPaste={handlePaste}
421
- placeholder={placeholder ?? "Message the agent…"}
422
- disabled={disabled}
423
- autoFocus={autoFocus}
424
- aria-label="Message the agent"
425
- aria-keyshortcuts="Enter Meta+Enter Control+Enter Shift+Enter"
426
- // A multiline textarea cannot legally take the combobox role or
427
- // aria-expanded. It remains a native textbox and advertises the
428
- // popup as list autocomplete; controls/active-descendant exist
429
- // only while the suggestion list itself exists.
430
- aria-autocomplete={paletteEnabled ? "list" : undefined}
431
- aria-controls={paletteEnabled && palette.open ? listboxId : undefined}
432
- aria-activedescendant={
433
- paletteEnabled && palette.open
434
- ? `${listboxId}-option-${palette.highlight}`
435
- : undefined
436
- }
437
- className={cn(
438
- // Font size steps up to 16px below `md` so iOS never zooms the
439
- // viewport on focus; desktop keeps the 15px og-md rhythm.
440
- "block w-full resize-none bg-transparent px-4 pt-3.5 pb-1 text-base leading-6 md:text-og-md",
441
- // The wrapper owns the whole-composer focus affordance (focus-within
442
- // border + soft glow). Suppress any self-scoped focus outline on the
443
- // textarea itself: `focus:outline-none` alone only sets outline-style
444
- // on `:focus`, which a host app's zero-specificity
445
- // `:where(...):focus-visible { outline: ... }` base rule re-applies as
446
- // the full shorthand. `focus-visible:outline-none` matches the same
447
- // state at class specificity and wins, so no second highlight (the
448
- // top-half rectangle bounded to the textarea box) ever paints.
449
- "text-og-fg placeholder:text-og-fg-subtle focus:outline-none focus-visible:outline-none",
450
- "disabled:cursor-not-allowed disabled:opacity-60",
451
- )}
452
- />
453
- {confirmState && pendingDangerCommand ? (
454
- <ConfirmBar
455
- command={pendingDangerCommand}
456
- onCancel={() => confirmState.resolve(false)}
457
- onConfirm={() => confirmState.resolve(true)}
458
- returnFocusRef={textareaRef}
459
- />
132
+ <Input placeholder={placeholder} autoFocus={autoFocus} />
133
+ {controller.confirmState ? (
134
+ <Confirmation />
460
135
  ) : (
461
- <div className="flex items-end gap-2 px-2.5 pb-2.5 pt-1">
462
- {attachments || models || controlsStart ? (
463
- // The control group wraps onto extra rows when it can't fit
464
- // (narrow viewports) instead of clipping under the rounded
465
- // corner; send/stop stays anchored bottom-right (shrink-0).
466
- <span className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
467
- {attachments ? (
468
- <>
469
- <input
470
- ref={fileInputRef}
471
- type="file"
472
- multiple
473
- className="hidden"
474
- onChange={handleFileChange}
475
- />
476
- <button
477
- type="button"
478
- disabled={disabled === true}
479
- onClick={() => fileInputRef.current?.click()}
480
- aria-label="Attach files"
481
- title="Attach files"
482
- className={cn(
483
- "inline-flex size-8 items-center justify-center rounded-og-md",
484
- "text-og-fg-muted transition-colors duration-150 hover:bg-og-surface-2 hover:text-og-fg",
485
- "disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:size-11",
486
- )}
487
- >
488
- <PaperclipIcon className="size-4" />
489
- </button>
490
- </>
491
- ) : null}
136
+ <Footer>
137
+ {hasControls ? (
138
+ <Controls>
139
+ <AttachButton />
140
+ {transcription ? <ComposerTranscriptionControl {...transcription} /> : null}
492
141
  {models ? (
493
- <ModelPicker
494
- models={models}
495
- value={selectedModel}
496
- onChange={(modelId) => onSelectModel?.(modelId)}
497
- disabled={disabled === true}
498
- />
142
+ <ModelPicker models={models} value={selectedModel} onChange={onSelectModel} />
499
143
  ) : null}
500
144
  {controlsStart}
501
- </span>
145
+ </Controls>
502
146
  ) : (
503
- <span className="min-w-0 flex-1 px-1.5 text-og-xs text-og-fg-subtle max-sm:hidden">
504
- {hint ?? "Enter to queue · Cmd/Ctrl+Enter to steer · Shift+Enter for a new line"}
505
- </span>
147
+ <Hint>{hint}</Hint>
506
148
  )}
507
- <span className="ml-auto flex shrink-0 items-center gap-1.5">
508
- <AnimatePresence initial={false}>
509
- {active || status === "paused" ? (
510
- <motion.button
511
- key="stop"
512
- type="button"
513
- initial={{ opacity: 0, scale: 0.8 }}
514
- animate={{ opacity: 1, scale: 1 }}
515
- exit={{ opacity: 0, scale: 0.8 }}
516
- transition={{ duration: 0.15, ease: "easeOut" }}
517
- onClick={() =>
518
- void (status === "paused" ? composer.resume() : composer.pause())
519
- }
520
- disabled={composer.pausing || composer.resuming}
521
- aria-label={status === "paused" ? "Resume the session" : "Pause the session"}
522
- title={
523
- status === "paused"
524
- ? "Resume the current inference before queued prompts"
525
- : "Pause the session; queued prompts are preserved"
526
- }
527
- className={cn(
528
- "inline-flex size-8 items-center justify-center rounded-og-md border border-og-border pointer-coarse:size-11",
529
- "bg-og-surface-2 text-og-fg-muted transition-colors duration-150",
530
- "hover:border-og-status-failed/50 hover:text-og-status-failed",
531
- "disabled:opacity-50",
532
- )}
533
- >
534
- {composer.pausing || composer.resuming ? (
535
- <LoaderCircleIcon className="size-3.5 animate-og-spin" />
536
- ) : status === "paused" ? (
537
- <PlayIcon className="size-3.5 fill-current" />
538
- ) : (
539
- <SquareIcon className="size-3 fill-current" />
540
- )}
541
- </motion.button>
542
- ) : null}
543
- </AnimatePresence>
544
- <button
545
- type="button"
546
- onClick={() => {
547
- if (blockedByUpload) {
548
- return;
549
- }
550
- void composer.send();
551
- }}
552
- disabled={!canSend || disabled === true || commandDraftBlocked}
553
- aria-label="Send message"
554
- title="Queue message (Enter); steer with Cmd/Ctrl+Enter"
555
- className={cn(
556
- "inline-flex size-8 items-center justify-center rounded-og-md pointer-coarse:size-11",
557
- "bg-og-accent text-og-accent-fg shadow-og-sm",
558
- "transition-[background-color,transform,opacity] duration-150 ease-og-spring",
559
- "hover:bg-og-accent-strong active:scale-95",
560
- "disabled:cursor-not-allowed disabled:bg-og-surface-3 disabled:text-og-fg-subtle disabled:shadow-none",
561
- )}
562
- >
563
- {composer.sending ? (
564
- <LoaderCircleIcon className="size-4 animate-og-spin" />
565
- ) : (
566
- <ArrowUpIcon className="size-4" />
567
- )}
568
- </button>
569
- </span>
570
- </div>
149
+ <Actions>
150
+ <PauseButton />
151
+ <SendButton />
152
+ </Actions>
153
+ </Footer>
571
154
  )}
572
- </div>
573
- </div>
574
- <AnimatePresence>
575
- {helpOpen ? <HelpPanel commands={helpCommands} onClose={() => setHelpOpen(false)} /> : null}
576
- </AnimatePresence>
577
- <AnimatePresence>
578
- {activeNotice ? (
579
- <motion.p
580
- initial={{ opacity: 0, height: 0 }}
581
- animate={{ opacity: 1, height: "auto" }}
582
- exit={{ opacity: 0, height: 0 }}
583
- className={cn(
584
- "overflow-hidden px-1 pt-1.5 text-xs",
585
- activeNotice.tone === "ok" ? "text-og-fg-muted" : "text-og-status-failed",
586
- )}
587
- role={activeNotice.tone === "error" ? "alert" : "status"}
588
- onAnimationComplete={() => {
589
- if (activeNotice.tone === "ok") {
590
- // Auto-dismiss success notices after a beat.
591
- window.setTimeout(
592
- () => setNotice((current) => (current === activeNotice ? null : current)),
593
- 2400,
594
- );
595
- }
596
- }}
597
- >
598
- {activeNotice.message}
599
- </motion.p>
600
- ) : null}
601
- </AnimatePresence>
602
- </div>
603
- );
604
- }
605
-
606
- /**
607
- * The danger confirm bar — reuses og-status-failed tokens (like the stop
608
- * control). Inline (not a modal) but keyboard-complete: Escape cancels, focus
609
- * moves to the primary action when it appears and returns to the composer on
610
- * dismiss, and the primary button names the action rather than a bare "Confirm".
611
- */
612
- function ConfirmBar({
613
- command,
614
- onCancel,
615
- onConfirm,
616
- returnFocusRef,
617
- }: {
618
- command: SlashCommand;
619
- onCancel: () => void;
620
- onConfirm: () => void;
621
- /** Focus returns here when the bar dismisses (the composer textarea). */
622
- returnFocusRef?: RefObject<HTMLTextAreaElement | null> | undefined;
623
- }) {
624
- const confirmRef = useRef<HTMLButtonElement | null>(null);
625
- const descriptionId = useId();
626
- useEffect(() => {
627
- const returnTo = returnFocusRef?.current ?? null;
628
- confirmRef.current?.focus();
629
- return () => {
630
- returnTo?.focus();
631
- };
632
- }, [returnFocusRef]);
633
- return (
634
- <div
635
- role="alertdialog"
636
- aria-label={`Confirm /${command.name}`}
637
- aria-describedby={descriptionId}
638
- data-testid="danger-confirm"
639
- onKeyDown={(event) => {
640
- if (event.key === "Escape") {
641
- event.preventDefault();
642
- event.stopPropagation();
643
- onCancel();
644
- }
645
- }}
646
- className="flex items-end justify-between gap-2 px-2.5 pb-2.5 pt-1"
647
- >
648
- <span id={descriptionId} className="min-w-0 flex-1 px-1.5 text-og-sm text-og-status-failed">
649
- Run <span className="font-mono">/{command.name}</span>? {command.description}
650
- </span>
651
- <span className="flex shrink-0 items-center gap-1.5">
652
- <button
653
- type="button"
654
- onClick={onCancel}
655
- className="rounded-og-md border border-og-border bg-og-surface-2 px-2.5 py-1 text-og-sm text-og-fg-muted hover:bg-og-surface-3 pointer-coarse:min-h-10"
656
- >
657
- Cancel
658
- </button>
659
- <button
660
- ref={confirmRef}
661
- type="button"
662
- onClick={onConfirm}
663
- className="rounded-og-md border border-og-status-failed/50 bg-og-status-failed/15 px-2.5 py-1 text-og-sm text-og-status-failed hover:bg-og-status-failed/25 pointer-coarse:min-h-10"
664
- >
665
- Run /{command.name}
666
- </button>
667
- </span>
668
- </div>
669
- );
670
- }
671
-
672
- /**
673
- * The attachment-chips strip rendered above the textarea while files are
674
- * attached. Each chip shows an image preview (or a type icon), the filename,
675
- * an upload/size/failed status line, and a remove control. A failed upload
676
- * surfaces its actual error (inline, full text on hover via `title`) and offers
677
- * a retry alongside remove — a failed attachment never blocks send (only an
678
- * in-progress upload does), and removing it clears the way. Styled with the
679
- * package's og-* tokens so it themes in any consumer.
680
- */
681
- function AttachmentChips({
682
- attachments,
683
- onRemove,
684
- onRetry,
685
- }: {
686
- attachments: UseFileAttachmentsResult["attachments"];
687
- onRemove: (id: string) => void;
688
- onRetry?: ((id: string) => void) | undefined;
689
- }) {
690
- return (
691
- <div className="flex flex-wrap gap-2 border-b border-og-border px-3 py-2">
692
- {attachments.map((attachment) => {
693
- const failed = attachment.status === "failed";
694
- const statusText =
695
- attachment.status === "uploading"
696
- ? "Uploading"
697
- : failed
698
- ? attachment.error || "Upload failed"
699
- : formatBytes(attachment.sizeBytes);
700
- return (
701
- <div
702
- key={attachment.id}
703
- className={cn(
704
- "flex min-w-0 max-w-[240px] items-center gap-2 rounded-og-md border px-2 py-1.5 text-og-sm",
705
- failed
706
- ? "border-og-status-failed/40 bg-og-status-failed/10"
707
- : "border-og-border bg-og-surface-2",
708
- )}
709
- >
710
- {attachment.previewUrl ? (
711
- <img
712
- src={attachment.previewUrl}
713
- alt=""
714
- className="size-8 shrink-0 rounded object-cover"
715
- />
716
- ) : attachment.contentType.startsWith("image/") ? (
717
- <ImageIcon className="size-4 shrink-0 text-og-fg-muted" />
718
- ) : (
719
- <FileIcon className="size-4 shrink-0 text-og-fg-muted" />
720
- )}
721
- <div className="min-w-0 flex-1">
722
- <div className="truncate font-medium text-og-fg">{attachment.name}</div>
723
- <div
724
- className={cn(
725
- "truncate text-og-xs",
726
- failed ? "text-og-status-failed" : "text-og-fg-subtle",
727
- )}
728
- title={failed ? statusText : undefined}
729
- >
730
- {statusText}
731
- </div>
732
- </div>
733
- {attachment.status === "uploading" ? (
734
- <LoaderCircleIcon className="size-3.5 shrink-0 animate-og-spin" />
735
- ) : null}
736
- {failed && onRetry ? (
737
- <button
738
- type="button"
739
- onClick={() => onRetry(attachment.id)}
740
- className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg pointer-coarse:size-10"
741
- aria-label={`Retry ${attachment.name}`}
742
- title="Retry upload"
743
- >
744
- <RotateCwIcon className="size-3.5" />
745
- </button>
746
- ) : null}
747
- <button
748
- type="button"
749
- onClick={() => onRemove(attachment.id)}
750
- className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg pointer-coarse:size-10"
751
- aria-label={`Remove ${attachment.name}`}
752
- >
753
- <XIcon className="size-3.5" />
754
- </button>
755
- </div>
756
- );
757
- })}
758
- </div>
759
- );
760
- }
761
-
762
- /** The in-composer /help panel, rendered entirely from the registry. */
763
- function HelpPanel({
764
- commands,
765
- onClose,
766
- }: {
767
- commands: readonly SlashCommand[];
768
- onClose: () => void;
769
- }) {
770
- return (
771
- <motion.div
772
- initial={{ opacity: 0, height: 0 }}
773
- animate={{ opacity: 1, height: "auto" }}
774
- exit={{ opacity: 0, height: 0 }}
775
- className="mt-2 overflow-hidden rounded-og-lg border border-og-border bg-og-surface-2"
776
- >
777
- <div className="flex items-center justify-between border-b border-og-border px-3 py-1.5">
778
- <span className="text-og-sm font-medium text-og-fg">Commands</span>
779
- <button
780
- type="button"
781
- onClick={onClose}
782
- className="text-og-xs text-og-fg-subtle hover:text-og-fg"
783
- >
784
- Close
785
- </button>
786
- </div>
787
- <ul className="py-1">
788
- {commands.map((command) => {
789
- const hint = argHint(command.args);
790
- return (
791
- <li key={command.name} className="flex items-baseline gap-2 px-3 py-1">
792
- <span className="font-mono text-og-sm text-og-accent">
793
- /{command.name}
794
- {hint ? <span className="ml-1 text-og-fg-subtle">{hint}</span> : null}
795
- </span>
796
- <span className="text-og-sm text-og-fg-muted">{command.description}</span>
797
- {command.danger ? (
798
- <span className="ml-auto rounded-og-xs bg-og-status-failed/15 px-1 text-og-xs uppercase tracking-wide text-og-status-failed">
799
- danger
800
- </span>
801
- ) : null}
802
- </li>
803
- );
804
- })}
805
- </ul>
806
- </motion.div>
155
+ </Surface>
156
+ </Frame>
157
+ <Help />
158
+ <Status />
159
+ </Root>
807
160
  );
808
161
  }