@opengeni/react 0.1.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.
@@ -0,0 +1,619 @@
1
+ import type { SessionStatus } from "@opengeni/sdk";
2
+ import { ArrowUpIcon, FileIcon, ImageIcon, LoaderCircleIcon, PaperclipIcon, SquareIcon, XIcon } from "lucide-react";
3
+ import { AnimatePresence, motion } from "motion/react";
4
+ import { useCallback, useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type ClipboardEvent, type DragEvent, type KeyboardEvent, type ReactNode } from "react";
5
+ import { argHint } from "../commands/registry";
6
+ import type { Notice, SlashCommand } from "../commands/types";
7
+ import type { ComposerState } from "../hooks/use-composer";
8
+ import { shouldSubmitOnKey } from "../hooks/use-composer";
9
+ import type { UseFileAttachmentsResult } from "../hooks/use-file-attachments";
10
+ import { defaultCommands } from "../commands/registry";
11
+ import { useSlashCommands, type ConfirmState, type SlashCommandContext } from "../hooks/use-slash-commands";
12
+ import { cn } from "../lib/cn";
13
+ import { formatBytes } from "../lib/format";
14
+ import { CommandPalette } from "./command-palette";
15
+
16
+ export type ChatComposerProps = {
17
+ composer: ComposerState;
18
+ /** Current session status; shows the stop control while a turn runs. */
19
+ status?: SessionStatus | null | undefined;
20
+ placeholder?: string | undefined;
21
+ disabled?: boolean | undefined;
22
+ autoFocus?: boolean | undefined;
23
+ /** Replaces the default keyboard hint under the field. */
24
+ hint?: string | undefined;
25
+ /** App controls (model picker, attach button, ...) in the footer row, replacing the hint. */
26
+ controlsStart?: ReactNode | undefined;
27
+ /** Content rendered above the textarea, inside the field chrome (e.g. attachment chips). */
28
+ header?: ReactNode | undefined;
29
+ /** Paste hook on the textarea (e.g. paste-image-to-attach). */
30
+ onPaste?: ((event: ClipboardEvent<HTMLTextAreaElement>) => void) | undefined;
31
+ /**
32
+ * Opt-in file attachments. When supplied (e.g. from {@link useFileAttachments}),
33
+ * the composer renders a built-in attach button (prepended to `controlsStart`),
34
+ * an attachment-chips strip (above the textarea, before any host `header`),
35
+ * routes paste through `addFromPaste` (image/* filter lives in the hook), and
36
+ * gates send while `uploading` so a message never departs without its files.
37
+ * Absent → no attachment UI renders and the composer behaves exactly as before.
38
+ */
39
+ attachments?: UseFileAttachmentsResult | undefined;
40
+ className?: string | undefined;
41
+ /**
42
+ * Slash-command palette. Defaults to the built-in {@link defaultCommands};
43
+ * apps concat their own. Backward-compatible: when `commandContext` is absent
44
+ * the palette is inert and behavior is identical to before.
45
+ */
46
+ commands?: readonly SlashCommand[] | undefined;
47
+ /**
48
+ * Wiring the palette needs to run server commands and gate visibility. The
49
+ * composer supplies notice/openHelp/clearView/confirm internally.
50
+ */
51
+ commandContext?: SlashCommandContext | undefined;
52
+ /** Reset the local timeline view (the /clear-view command target). */
53
+ onClearView?: (() => void) | undefined;
54
+ };
55
+
56
+ const ACTIVE_STATUSES: ReadonlySet<SessionStatus> = new Set(["queued", "running"]);
57
+
58
+ /**
59
+ * The chat composer — the only human-to-agent input surface. Plain chat in,
60
+ * everything else is the agent's job. Enter sends, Shift+Enter breaks the
61
+ * line, and the stop control appears while a turn is running (sending while
62
+ * running is legitimate steering, so send stays available too).
63
+ *
64
+ * Typing a leading "/" opens the slash-command palette — SESSION/OPERATOR
65
+ * controls (clear, compact, pause goal, help), never a structured channel to
66
+ * the agent. The palette is purely additive: with no `commandContext` it is
67
+ * inert and the composer behaves exactly as before.
68
+ */
69
+ export function ChatComposer({
70
+ composer,
71
+ status,
72
+ placeholder,
73
+ disabled,
74
+ autoFocus,
75
+ hint,
76
+ controlsStart,
77
+ header,
78
+ onPaste,
79
+ attachments,
80
+ className,
81
+ commands = defaultCommands,
82
+ commandContext,
83
+ onClearView,
84
+ }: ChatComposerProps) {
85
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
86
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
87
+ const active = status != null && ACTIVE_STATUSES.has(status);
88
+
89
+ // Block sends while attachments are still uploading so a message never
90
+ // departs without the files the user attached to it. This gates BOTH the
91
+ // Enter-to-send path (which calls composer.send directly, bypassing canSend)
92
+ // and the send button — dropping either path could ship a fileless message.
93
+ const blockedByUpload = attachments?.uploading === true;
94
+
95
+ // A ready attachment makes a file-only message (empty draft) sendable. The
96
+ // composer (when wired with `sendExtras.resources`) already reflects this in
97
+ // `canSend`; we OR it in here too so send-enablement is correct even for a
98
+ // composer whose canSend doesn't know about attachments — and so this stays
99
+ // the single home of the attachment send-gate.
100
+ const hasReadyAttachment = (attachments?.readyResources.length ?? 0) > 0;
101
+ // The send affordance: text OR a ready attachment, never mid-upload or mid-send.
102
+ const canSend = (composer.canSend || hasReadyAttachment) && !blockedByUpload && !composer.sending;
103
+
104
+ // Drag-and-drop file attach: only a drop target when `attachments` is wired,
105
+ // and only reacts to drags that actually carry files (so it never hijacks
106
+ // normal text drag/drop). `dragging` drives the drop overlay.
107
+ const [dragging, setDragging] = useState(false);
108
+ const dragCarriesFiles = (event: { dataTransfer: DataTransfer | null }): boolean =>
109
+ event.dataTransfer != null && [...event.dataTransfer.types].includes("Files");
110
+ const handleDragOver = useCallback(
111
+ (event: DragEvent<HTMLDivElement>) => {
112
+ if (!attachments || !dragCarriesFiles(event)) {
113
+ return;
114
+ }
115
+ // preventDefault marks this a valid drop target so the browser fires drop.
116
+ event.preventDefault();
117
+ setDragging(true);
118
+ },
119
+ [attachments],
120
+ );
121
+ const handleDragLeave = useCallback(
122
+ (event: DragEvent<HTMLDivElement>) => {
123
+ if (!attachments) {
124
+ return;
125
+ }
126
+ // Ignore leaves bubbling from children: only clear when the pointer left
127
+ // the composer bounds entirely (the related target is outside it).
128
+ if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
129
+ return;
130
+ }
131
+ setDragging(false);
132
+ },
133
+ [attachments],
134
+ );
135
+ const handleDrop = useCallback(
136
+ (event: DragEvent<HTMLDivElement>) => {
137
+ if (!attachments || !dragCarriesFiles(event)) {
138
+ return;
139
+ }
140
+ event.preventDefault();
141
+ setDragging(false);
142
+ // Same path the picker uses: addFiles accepts ALL files (no image filter).
143
+ if (event.dataTransfer.files.length > 0) {
144
+ attachments.addFiles(event.dataTransfer.files);
145
+ }
146
+ },
147
+ [attachments],
148
+ );
149
+
150
+ const [notice, setNotice] = useState<Notice | null>(null);
151
+ const [helpOpen, setHelpOpen] = useState(false);
152
+ const [confirmState, setConfirmState] = useState<ConfirmState>(null);
153
+ const listboxId = useId();
154
+
155
+ const resize = useCallback(() => {
156
+ const textarea = textareaRef.current;
157
+ if (!textarea) {
158
+ return;
159
+ }
160
+ textarea.style.height = "0px";
161
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`;
162
+ }, []);
163
+
164
+ useEffect(() => {
165
+ resize();
166
+ }, [composer.value, resize]);
167
+
168
+ // The UI affordances commands reach: surface a notice, open the help panel,
169
+ // reset the local view, and the danger confirm flow (resolves when the
170
+ // operator confirms/cancels in the confirm bar).
171
+ const handlers = useMemo(
172
+ () => ({
173
+ notice: (next: Notice) => {
174
+ setNotice(next);
175
+ composer.clearError();
176
+ },
177
+ openHelp: () => setHelpOpen(true),
178
+ // Report whether a view-reset was actually wired by the host: with no
179
+ // onClearView the command is a no-op and must say so (not a false success).
180
+ clearView: () => {
181
+ if (!onClearView) {
182
+ return false;
183
+ }
184
+ onClearView();
185
+ return true;
186
+ },
187
+ // The hook binds the command actually being run into confirm() (see
188
+ // use-slash-commands buildContext), so the confirm bar renders from THAT
189
+ // command — never a near-match highlighted in the palette. This is what
190
+ // keeps the destructive /clear from being mislabeled as /clear-view.
191
+ confirm: (command: SlashCommand) =>
192
+ new Promise<boolean>((resolve) => {
193
+ setConfirmState({
194
+ command,
195
+ resolve: (confirmed) => {
196
+ setConfirmState(null);
197
+ resolve(confirmed);
198
+ },
199
+ });
200
+ }),
201
+ }),
202
+ [composer, onClearView],
203
+ );
204
+
205
+ const palette = useSlashCommands({
206
+ commands,
207
+ context: commandContext,
208
+ handlers,
209
+ value: composer.value,
210
+ setValue: composer.setValue,
211
+ });
212
+
213
+ // The confirm bar renders from the command the hook is actually running —
214
+ // carried into confirmState by handlers.confirm — NOT a near-match that
215
+ // happens to be highlighted/active in the palette. (Re-deriving it from
216
+ // palette.items[palette.highlight] mislabeled the destructive /clear as the
217
+ // harmless /clear-view, since clear-view prefix-matches "clear" and sorts
218
+ // first.)
219
+ const pendingDangerCommand = confirmState ? confirmState.command : null;
220
+
221
+ const paletteEnabled = commandContext !== undefined;
222
+
223
+ // A slash-command draft must never be delivered to the agent as chat — it is
224
+ // an operator control, not a message. The palette consumes Enter while open,
225
+ // but after Escape the popover is closed yet the draft still matches a command;
226
+ // block the send path (here and on the send button) so "/clear" can't be sent.
227
+ const commandDraftBlocked = paletteEnabled && palette.isCommandDraft;
228
+
229
+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
230
+ // Palette key handling runs FIRST and only when the palette is open; when
231
+ // closed it returns false and the existing send path is untouched.
232
+ if (paletteEnabled && palette.onKeyDown(event)) {
233
+ return;
234
+ }
235
+ if (shouldSubmitOnKey(event)) {
236
+ event.preventDefault();
237
+ if (commandDraftBlocked) {
238
+ // Dismissed palette + a "/command" draft: don't send it as chat. Nudge
239
+ // the operator to re-open the palette (any edit re-opens it) or clear it.
240
+ setNotice({ tone: "error", message: "That's a slash command — press Enter in the command list to run it, or edit the line to send a message." });
241
+ return;
242
+ }
243
+ if (blockedByUpload) {
244
+ // Files are still uploading: swallow the Enter so the message can't
245
+ // depart without them. The send button is disabled in the same state.
246
+ return;
247
+ }
248
+ void composer.send();
249
+ }
250
+ };
251
+
252
+ // The host's onPaste still fires (model/tool/whatever paste handling); when
253
+ // attachments are wired, also feed the clipboard through addFromPaste so the
254
+ // image/* filter (owned by the hook) attaches pasted images.
255
+ const handlePaste = useCallback(
256
+ (event: ClipboardEvent<HTMLTextAreaElement>) => {
257
+ onPaste?.(event);
258
+ attachments?.addFromPaste(event);
259
+ },
260
+ [onPaste, attachments],
261
+ );
262
+
263
+ const handleFileChange = useCallback(
264
+ (event: ChangeEvent<HTMLInputElement>) => {
265
+ if (event.target.files) {
266
+ attachments?.addFiles(event.target.files);
267
+ }
268
+ event.target.value = "";
269
+ },
270
+ [attachments],
271
+ );
272
+
273
+ const helpCommands = useMemo(
274
+ () =>
275
+ commands.filter((command) => {
276
+ if (command.permission && commandContext) {
277
+ const perms = commandContext.permissions;
278
+ return perms.includes(command.permission) || perms.includes("workspace:admin");
279
+ }
280
+ return true;
281
+ }),
282
+ [commands, commandContext],
283
+ );
284
+
285
+ const activeNotice = notice ?? (composer.error ? { tone: "error" as const, message: composer.error.message || "Sending failed — your draft is still here. Try again." } : null);
286
+
287
+ return (
288
+ <div className={cn("og-root", className)}>
289
+ <div className="relative">
290
+ {paletteEnabled ? (
291
+ <CommandPalette
292
+ open={palette.open && confirmState === null}
293
+ items={palette.items}
294
+ highlight={palette.highlight}
295
+ onHighlight={palette.setHighlight}
296
+ onRun={(index) => {
297
+ // Run the CLICKED row directly. We must not route a pointer click
298
+ // through runHighlighted: its exact-match override would re-resolve
299
+ // "/clear" to the destructive clear even when the operator clicked
300
+ // the harmless clear-view row. runAt honors the explicit selection.
301
+ palette.setHighlight(index);
302
+ void palette.runAt(index);
303
+ }}
304
+ argHintText={palette.activeArgHint}
305
+ listboxId={listboxId}
306
+ />
307
+ ) : null}
308
+ <div
309
+ // Drag-and-drop file attach lives on the field wrapper, but only when
310
+ // `attachments` is wired — without it the composer is not a drop target
311
+ // and behaves exactly as before.
312
+ onDragOver={attachments ? handleDragOver : undefined}
313
+ onDragLeave={attachments ? handleDragLeave : undefined}
314
+ onDrop={attachments ? handleDrop : undefined}
315
+ className={cn(
316
+ "relative rounded-og-lg border border-og-border bg-og-surface-1 shadow-og-sm",
317
+ "transition-[border-color,box-shadow] duration-200",
318
+ "focus-within:border-og-accent/60 focus-within:shadow-og-glow",
319
+ // While files are dragged over, swap to a dashed accent border to
320
+ // signal a live drop target (the overlay carries the label).
321
+ dragging && "border-dashed border-og-accent",
322
+ )}
323
+ >
324
+ {dragging ? (
325
+ <div
326
+ aria-hidden
327
+ className={cn(
328
+ "pointer-events-none absolute inset-0 z-10 flex items-center justify-center",
329
+ "rounded-og-lg bg-og-surface-1/85 text-sm font-medium text-og-accent backdrop-blur-[1px]",
330
+ )}
331
+ >
332
+ <span className="inline-flex items-center gap-2">
333
+ <PaperclipIcon className="size-4" />
334
+ Drop files to attach
335
+ </span>
336
+ </div>
337
+ ) : null}
338
+ {attachments && attachments.attachments.length > 0 ? (
339
+ <AttachmentChips attachments={attachments.attachments} onRemove={attachments.remove} />
340
+ ) : null}
341
+ {header}
342
+ <textarea
343
+ ref={textareaRef}
344
+ rows={1}
345
+ value={composer.value}
346
+ onChange={(event) => composer.setValue(event.target.value)}
347
+ onKeyDown={onKeyDown}
348
+ onPaste={handlePaste}
349
+ placeholder={placeholder ?? "Message the agent…"}
350
+ disabled={disabled}
351
+ autoFocus={autoFocus}
352
+ aria-label="Message the agent"
353
+ role={paletteEnabled && palette.open ? "combobox" : undefined}
354
+ aria-expanded={paletteEnabled ? palette.open : undefined}
355
+ aria-controls={paletteEnabled && palette.open ? listboxId : undefined}
356
+ aria-activedescendant={paletteEnabled && palette.open ? `${listboxId}-option-${palette.highlight}` : undefined}
357
+ className={cn(
358
+ "block w-full resize-none bg-transparent px-4 pt-3.5 pb-1 text-[15px] leading-6",
359
+ // The wrapper owns the whole-composer focus affordance (focus-within
360
+ // border + soft glow). Suppress any self-scoped focus outline on the
361
+ // textarea itself: `focus:outline-none` alone only sets outline-style
362
+ // on `:focus`, which a host app's zero-specificity
363
+ // `:where(...):focus-visible { outline: ... }` base rule re-applies as
364
+ // the full shorthand. `focus-visible:outline-none` matches the same
365
+ // state at class specificity and wins, so no second highlight (the
366
+ // top-half rectangle bounded to the textarea box) ever paints.
367
+ "text-og-fg placeholder:text-og-fg-subtle focus:outline-none focus-visible:outline-none",
368
+ "disabled:cursor-not-allowed disabled:opacity-60",
369
+ )}
370
+ />
371
+ {confirmState && pendingDangerCommand ? (
372
+ <ConfirmBar
373
+ command={pendingDangerCommand}
374
+ onCancel={() => confirmState.resolve(false)}
375
+ onConfirm={() => confirmState.resolve(true)}
376
+ />
377
+ ) : (
378
+ <div className="flex items-center justify-between gap-2 px-2.5 pb-2.5 pt-1">
379
+ {attachments || controlsStart ? (
380
+ <span className="flex min-w-0 items-center gap-1.5">
381
+ {attachments ? (
382
+ <>
383
+ <input
384
+ ref={fileInputRef}
385
+ type="file"
386
+ multiple
387
+ className="hidden"
388
+ onChange={handleFileChange}
389
+ />
390
+ <button
391
+ type="button"
392
+ disabled={disabled === true}
393
+ onClick={() => fileInputRef.current?.click()}
394
+ aria-label="Attach files"
395
+ title="Attach files"
396
+ className={cn(
397
+ "inline-flex size-8 items-center justify-center rounded-og-md",
398
+ "text-og-fg-muted transition-colors duration-150 hover:bg-og-surface-2 hover:text-og-fg",
399
+ "disabled:cursor-not-allowed disabled:opacity-50",
400
+ )}
401
+ >
402
+ <PaperclipIcon className="size-4" />
403
+ </button>
404
+ </>
405
+ ) : null}
406
+ {controlsStart}
407
+ </span>
408
+ ) : (
409
+ <span className="px-1.5 text-[11px] text-og-fg-subtle max-sm:hidden">
410
+ {hint ?? "Enter to send · Shift+Enter for a new line · / for commands"}
411
+ </span>
412
+ )}
413
+ <span className="flex items-center gap-1.5">
414
+ <AnimatePresence initial={false}>
415
+ {active ? (
416
+ <motion.button
417
+ key="stop"
418
+ type="button"
419
+ initial={{ opacity: 0, scale: 0.8 }}
420
+ animate={{ opacity: 1, scale: 1 }}
421
+ exit={{ opacity: 0, scale: 0.8 }}
422
+ transition={{ duration: 0.15, ease: "easeOut" }}
423
+ onClick={() => void composer.interrupt()}
424
+ disabled={composer.interrupting}
425
+ aria-label="Stop the current turn"
426
+ title="Stop the current turn"
427
+ className={cn(
428
+ "inline-flex size-8 items-center justify-center rounded-og-md border border-og-border",
429
+ "bg-og-surface-2 text-og-fg-muted transition-colors duration-150",
430
+ "hover:border-og-status-failed/50 hover:text-og-status-failed",
431
+ "disabled:opacity-50",
432
+ )}
433
+ >
434
+ {composer.interrupting ? (
435
+ <LoaderCircleIcon className="size-3.5 animate-og-spin" />
436
+ ) : (
437
+ <SquareIcon className="size-3 fill-current" />
438
+ )}
439
+ </motion.button>
440
+ ) : null}
441
+ </AnimatePresence>
442
+ <button
443
+ type="button"
444
+ onClick={() => {
445
+ if (blockedByUpload) {
446
+ return;
447
+ }
448
+ void composer.send();
449
+ }}
450
+ disabled={!canSend || disabled === true || commandDraftBlocked}
451
+ aria-label="Send message"
452
+ className={cn(
453
+ "inline-flex size-8 items-center justify-center rounded-og-md",
454
+ "bg-og-accent text-og-accent-fg shadow-og-sm",
455
+ "transition-[background-color,transform,opacity] duration-150 ease-og-spring",
456
+ "hover:bg-og-accent-strong active:scale-95",
457
+ "disabled:cursor-not-allowed disabled:bg-og-surface-3 disabled:text-og-fg-subtle disabled:shadow-none",
458
+ )}
459
+ >
460
+ {composer.sending ? <LoaderCircleIcon className="size-4 animate-og-spin" /> : <ArrowUpIcon className="size-4" />}
461
+ </button>
462
+ </span>
463
+ </div>
464
+ )}
465
+ </div>
466
+ </div>
467
+ <AnimatePresence>
468
+ {helpOpen ? (
469
+ <HelpPanel commands={helpCommands} onClose={() => setHelpOpen(false)} />
470
+ ) : null}
471
+ </AnimatePresence>
472
+ <AnimatePresence>
473
+ {activeNotice ? (
474
+ <motion.p
475
+ initial={{ opacity: 0, height: 0 }}
476
+ animate={{ opacity: 1, height: "auto" }}
477
+ exit={{ opacity: 0, height: 0 }}
478
+ className={cn(
479
+ "overflow-hidden px-1 pt-1.5 text-xs",
480
+ activeNotice.tone === "ok" ? "text-og-fg-muted" : "text-og-status-failed",
481
+ )}
482
+ role={activeNotice.tone === "error" ? "alert" : "status"}
483
+ onAnimationComplete={() => {
484
+ if (activeNotice.tone === "ok") {
485
+ // Auto-dismiss success notices after a beat.
486
+ window.setTimeout(() => setNotice((current) => (current === activeNotice ? null : current)), 2400);
487
+ }
488
+ }}
489
+ >
490
+ {activeNotice.message}
491
+ </motion.p>
492
+ ) : null}
493
+ </AnimatePresence>
494
+ </div>
495
+ );
496
+ }
497
+
498
+ /** The danger confirm bar — reuses og-status-failed tokens (like the stop control). */
499
+ function ConfirmBar({ command, onCancel, onConfirm }: { command: SlashCommand; onCancel: () => void; onConfirm: () => void }) {
500
+ return (
501
+ <div
502
+ role="alertdialog"
503
+ aria-label={`Confirm /${command.name}`}
504
+ data-testid="danger-confirm"
505
+ className="flex items-center justify-between gap-2 px-2.5 pb-2.5 pt-1"
506
+ >
507
+ <span className="px-1.5 text-[12px] text-og-status-failed">
508
+ Run <span className="font-mono">/{command.name}</span>? {command.description}
509
+ </span>
510
+ <span className="flex items-center gap-1.5">
511
+ <button
512
+ type="button"
513
+ onClick={onCancel}
514
+ className="rounded-og-md border border-og-border bg-og-surface-2 px-2.5 py-1 text-[12px] text-og-fg-muted hover:bg-og-surface-3"
515
+ >
516
+ Cancel
517
+ </button>
518
+ <button
519
+ type="button"
520
+ autoFocus
521
+ onClick={onConfirm}
522
+ className="rounded-og-md border border-og-status-failed/50 bg-og-status-failed/15 px-2.5 py-1 text-[12px] text-og-status-failed hover:bg-og-status-failed/25"
523
+ >
524
+ Confirm
525
+ </button>
526
+ </span>
527
+ </div>
528
+ );
529
+ }
530
+
531
+ /**
532
+ * The attachment-chips strip rendered above the textarea while files are
533
+ * attached. Each chip shows an image preview (or a type icon), the filename,
534
+ * an upload/size/failed status line, and a remove control. Styled with the
535
+ * package's og-* tokens so it themes in any consumer.
536
+ */
537
+ function AttachmentChips({ attachments, onRemove }: {
538
+ attachments: UseFileAttachmentsResult["attachments"];
539
+ onRemove: (id: string) => void;
540
+ }) {
541
+ return (
542
+ <div className="flex flex-wrap gap-2 border-b border-og-border px-3 py-2">
543
+ {attachments.map((attachment) => (
544
+ <div
545
+ key={attachment.id}
546
+ className={cn(
547
+ "flex min-w-0 max-w-[240px] items-center gap-2 rounded-og-md border px-2 py-1.5",
548
+ "border-og-border bg-og-surface-2 text-xs",
549
+ )}
550
+ >
551
+ {attachment.previewUrl ? (
552
+ <img src={attachment.previewUrl} alt="" className="size-8 shrink-0 rounded object-cover" />
553
+ ) : attachment.contentType.startsWith("image/") ? (
554
+ <ImageIcon className="size-4 shrink-0 text-og-fg-muted" />
555
+ ) : (
556
+ <FileIcon className="size-4 shrink-0 text-og-fg-muted" />
557
+ )}
558
+ <div className="min-w-0 flex-1">
559
+ <div className="truncate font-medium text-og-fg">{attachment.name}</div>
560
+ <div className={cn(
561
+ "truncate text-[11px]",
562
+ attachment.status === "failed" ? "text-og-status-failed" : "text-og-fg-subtle",
563
+ )}
564
+ >
565
+ {attachment.status === "uploading" ? "Uploading" : attachment.status === "failed" ? "Upload failed" : formatBytes(attachment.sizeBytes)}
566
+ </div>
567
+ </div>
568
+ {attachment.status === "uploading" ? <LoaderCircleIcon className="size-3.5 shrink-0 animate-og-spin" /> : null}
569
+ <button
570
+ type="button"
571
+ onClick={() => onRemove(attachment.id)}
572
+ className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg"
573
+ aria-label={`Remove ${attachment.name}`}
574
+ >
575
+ <XIcon className="size-3.5" />
576
+ </button>
577
+ </div>
578
+ ))}
579
+ </div>
580
+ );
581
+ }
582
+
583
+ /** The in-composer /help panel, rendered entirely from the registry. */
584
+ function HelpPanel({ commands, onClose }: { commands: readonly SlashCommand[]; onClose: () => void }) {
585
+ return (
586
+ <motion.div
587
+ initial={{ opacity: 0, height: 0 }}
588
+ animate={{ opacity: 1, height: "auto" }}
589
+ exit={{ opacity: 0, height: 0 }}
590
+ className="mt-2 overflow-hidden rounded-og-lg border border-og-border bg-og-surface-2"
591
+ >
592
+ <div className="flex items-center justify-between border-b border-og-border px-3 py-1.5">
593
+ <span className="text-[12px] font-medium text-og-fg">Commands</span>
594
+ <button type="button" onClick={onClose} className="text-[11px] text-og-fg-subtle hover:text-og-fg">
595
+ Close
596
+ </button>
597
+ </div>
598
+ <ul className="py-1">
599
+ {commands.map((command) => {
600
+ const hint = argHint(command.args);
601
+ return (
602
+ <li key={command.name} className="flex items-baseline gap-2 px-3 py-1">
603
+ <span className="font-mono text-[12px] text-og-accent">
604
+ /{command.name}
605
+ {hint ? <span className="ml-1 text-og-fg-subtle">{hint}</span> : null}
606
+ </span>
607
+ <span className="text-[12px] text-og-fg-muted">{command.description}</span>
608
+ {command.danger ? (
609
+ <span className="ml-auto rounded-og-xs bg-og-status-failed/15 px-1 text-[10px] uppercase tracking-wide text-og-status-failed">
610
+ danger
611
+ </span>
612
+ ) : null}
613
+ </li>
614
+ );
615
+ })}
616
+ </ul>
617
+ </motion.div>
618
+ );
619
+ }