@hilum/ui 3.4.0 → 3.5.1

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 (65) hide show
  1. package/README.md +4 -0
  2. package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
  3. package/dist/chunk-RI6QJR2L.js.map +1 -0
  4. package/dist/index.d.ts +789 -115
  5. package/dist/index.js +10404 -1099
  6. package/dist/index.js.map +1 -1
  7. package/dist/tokens.css +17 -0
  8. package/dist/tokens.d.ts +3 -0
  9. package/dist/tokens.js +1 -1
  10. package/llms.txt +18 -4
  11. package/package.json +19 -1
  12. package/src/components/accordion.tsx +746 -58
  13. package/src/components/alert-dialog.tsx +11 -5
  14. package/src/components/ask-user-questions.tsx +1366 -0
  15. package/src/components/badge.tsx +105 -24
  16. package/src/components/button.tsx +207 -38
  17. package/src/components/card.tsx +3 -2
  18. package/src/components/chat-message.tsx +116 -0
  19. package/src/components/checkbox-group.tsx +403 -0
  20. package/src/components/checkbox.tsx +6 -3
  21. package/src/components/color-input.tsx +3 -1
  22. package/src/components/color-picker.tsx +1992 -80
  23. package/src/components/dialog.tsx +141 -81
  24. package/src/components/dropdown-menu.tsx +21 -13
  25. package/src/components/dropdown.tsx +245 -0
  26. package/src/components/file-thumbnail.tsx +156 -0
  27. package/src/components/hover-card.tsx +2 -0
  28. package/src/components/input-copy.tsx +307 -0
  29. package/src/components/input-group.tsx +303 -108
  30. package/src/components/input-message.tsx +883 -0
  31. package/src/components/input.tsx +6 -3
  32. package/src/components/menu-item.tsx +156 -0
  33. package/src/components/mobile-drawer.tsx +50 -0
  34. package/src/components/nav-item.tsx +43 -0
  35. package/src/components/nav-menu.tsx +47 -0
  36. package/src/components/popover.tsx +3 -1
  37. package/src/components/radio-group.tsx +381 -35
  38. package/src/components/scroll-area.tsx +9 -2
  39. package/src/components/select.tsx +708 -121
  40. package/src/components/sheet.tsx +15 -6
  41. package/src/components/slider.tsx +1583 -20
  42. package/src/components/status-badge.tsx +2 -0
  43. package/src/components/switch.tsx +245 -28
  44. package/src/components/table.tsx +191 -77
  45. package/src/components/tabs-subtle.tsx +411 -0
  46. package/src/components/tabs.tsx +459 -49
  47. package/src/components/textarea.tsx +7 -3
  48. package/src/components/thinking-indicator.tsx +106 -0
  49. package/src/components/thinking-steps.tsx +382 -0
  50. package/src/components/tooltip.tsx +158 -33
  51. package/src/hooks/use-merge-split.tsx +359 -0
  52. package/src/hooks/use-proximity-hover.ts +208 -0
  53. package/src/index.ts +14 -0
  54. package/src/lib/elevated.tsx +52 -0
  55. package/src/lib/font-weight.ts +18 -0
  56. package/src/lib/icon-context.tsx +98 -0
  57. package/src/lib/icon-map.tsx +669 -0
  58. package/src/lib/interaction.ts +102 -0
  59. package/src/lib/scroll-fade.tsx +241 -0
  60. package/src/lib/shape-context.tsx +121 -0
  61. package/src/lib/springs.ts +30 -0
  62. package/src/lib/surface-classes.ts +43 -0
  63. package/src/lib/surface-context.tsx +17 -0
  64. package/src/tokens/tokens.ts +3 -0
  65. package/dist/chunk-MAHPAP65.js.map +0 -1
@@ -0,0 +1,883 @@
1
+ "use client";
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ChangeEvent,
12
+ type DragEvent as ReactDragEvent,
13
+ type HTMLAttributes,
14
+ type KeyboardEvent as ReactKeyboardEvent,
15
+ type ReactNode,
16
+ type TextareaHTMLAttributes,
17
+ } from "react";
18
+ import { AnimatePresence, motion, Reorder, useReducedMotion } from "framer-motion";
19
+ import { cn } from "../lib/utils";
20
+ import { fontWeights } from "../lib/font-weight";
21
+ import { spring } from "../lib/springs";
22
+ import { useShape } from "../lib/shape-context";
23
+ import { useIcon } from "../lib/icon-context";
24
+ import { surfaceClasses } from "../lib/surface-classes";
25
+ import { SurfaceProvider } from "../lib/surface-context";
26
+ import { FileThumbnail } from "./file-thumbnail";
27
+ import { Button } from "./button";
28
+ import { Tooltip } from "./tooltip";
29
+
30
+ const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
31
+
32
+ // Touch devices have no hover, so hover-revealed affordances (like a queued
33
+ // row's × button) would never appear. `(hover: none)` flags those so they can
34
+ // be shown persistently instead. SSR-safe: starts false, resolves on mount.
35
+ function useIsTouch() {
36
+ const [isTouch, setIsTouch] = useState(false);
37
+ useEffect(() => {
38
+ const mq = window.matchMedia("(hover: none)");
39
+ const update = () => setIsTouch(mq.matches);
40
+ update();
41
+ mq.addEventListener("change", update);
42
+ return () => mq.removeEventListener("change", update);
43
+ }, []);
44
+ return isTouch;
45
+ }
46
+
47
+ const DEFAULT_ACCEPT = "image/png,image/jpeg,application/pdf";
48
+
49
+ interface InputMessageSlotContext {
50
+ /** Opens the native file picker via the hidden `<input type="file">`.
51
+ * Pass `acceptOverride` (e.g. `"image/*"`) to scope the picker to a
52
+ * subset of the component's accept types just for this invocation. */
53
+ openFilePicker: (acceptOverride?: string) => void;
54
+ /** Currently-attached files (controlled). */
55
+ files: File[];
56
+ }
57
+
58
+ type InputMessageSlot = ReactNode | ((ctx: InputMessageSlotContext) => ReactNode);
59
+
60
+ /** A message held in the queue while the assistant is responding. Carries the
61
+ * trimmed text plus a snapshot of the files attached when it was queued, so
62
+ * double-click-to-edit can restore both. `id` is a stable key minted on enqueue. */
63
+ interface QueuedMessage {
64
+ id: string;
65
+ text: string;
66
+ files: File[];
67
+ }
68
+
69
+ interface InputMessageProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
70
+ /** Controlled textarea value. */
71
+ value: string;
72
+ /** Called with the new value on every textarea change. */
73
+ onValueChange: (value: string) => void;
74
+ /** Fired when the user submits (Enter or the send button) and when a queued
75
+ * message auto-dispatches. Receives the trimmed value, the attached files,
76
+ * and — for auto-dispatched queue items — `meta.queuedId` (the originating
77
+ * QueuedMessage id), so a consumer can e.g. morph the queued item into the
78
+ * sent message via a shared-layout (`layoutId`) transition. */
79
+ onSend?: (value: string, files: File[], meta?: { queuedId?: string }) => void;
80
+ /** Placeholder text shown when the value is empty. */
81
+ placeholder?: string;
82
+ /** Content rendered in the bottom-left action area. Can be a function that
83
+ * receives `{ openFilePicker, files }` to wire an attach button. */
84
+ leftSlot?: InputMessageSlot;
85
+ /** Content rendered in the bottom-right action area, before the built-in
86
+ * send button. Same render-fn shape as leftSlot. */
87
+ rightSlot?: InputMessageSlot;
88
+ /** Disables the textarea, send button, and drag-and-drop. */
89
+ disabled?: boolean;
90
+ /** Minimum visible rows before the textarea grows. */
91
+ minRows?: number;
92
+ /** Maximum visible rows before the textarea starts to scroll. */
93
+ maxRows?: number;
94
+ /** When false, clicking the surrounding container won't refocus the textarea. */
95
+ clickToFocus?: boolean;
96
+ /** Accessible label for the send button. */
97
+ sendLabel?: string;
98
+ /** Controlled list of attached files. When undefined, attachment behavior
99
+ * is disabled (no drag-drop, no file input). */
100
+ files?: File[];
101
+ /** Called when files are added (drag-drop or picker) or removed. */
102
+ onFilesChange?: (files: File[]) => void;
103
+ /** Accepted MIME types as a comma-separated string. Defaults to PNG / JPEG / PDF. */
104
+ accept?: string;
105
+ /** Maximum number of files. Extra files are dropped when the limit is exceeded. */
106
+ maxFiles?: number;
107
+ /** Side of each preview tile in pixels. Defaults to 80. */
108
+ filePreviewSize?: number;
109
+ /** Extra props forwarded to the underlying textarea. */
110
+ textareaProps?: Omit<
111
+ TextareaHTMLAttributes<HTMLTextAreaElement>,
112
+ "value" | "onChange" | "onKeyDown" | "disabled" | "placeholder"
113
+ >;
114
+ /** Assistant response state. When `"streaming"`, the send button becomes a
115
+ * Stop control (empty draft) or a Queue action (non-empty draft); on the
116
+ * `streaming → idle` edge the next queued message auto-dispatches via `onSend`.
117
+ * Leave undefined to keep the legacy send-immediately behavior. */
118
+ status?: "idle" | "streaming";
119
+ /** Fired when the Stop control is pressed (streaming, empty draft). The
120
+ * consumer should halt the current response and flip `status` to `"idle"`,
121
+ * which immediately dispatches the next queued message. */
122
+ onStop?: () => void;
123
+ /** Controlled queue of pending messages. Requires `status` to be controlled. */
124
+ queue?: QueuedMessage[];
125
+ /** Called when the queue changes (enqueue, edit, delete, reorder, dispatch). */
126
+ onQueueChange?: (queue: QueuedMessage[]) => void;
127
+ /** Render the built-in reorderable queue rows above the textarea. Set to
128
+ * `false` to suppress them and render the queue yourself (e.g. as full-width
129
+ * rows above the composer) — enqueue + auto-dispatch still run. */
130
+ showQueue?: boolean;
131
+ /** Previously-sent messages, oldest first. When the textarea is focused,
132
+ * ArrowUp (caret on the first line) recalls the previous one and walks
133
+ * backward through history; ArrowDown (caret on the last line) walks forward
134
+ * toward the in-progress draft. Editing or sending exits history mode. */
135
+ history?: string[];
136
+ }
137
+
138
+ // ─── File preview tile ────────────────────────────────────────────────────
139
+ // Composer-row tile: a FileThumbnail wrapped with enter/exit motion and a
140
+ // hover-revealed remove (×) button.
141
+ interface FilePreviewTileProps {
142
+ file: File;
143
+ onRemove: () => void;
144
+ size: number;
145
+ }
146
+
147
+ function FilePreviewTile({ file, onRemove, size }: FilePreviewTileProps) {
148
+ const XIcon = useIcon("x");
149
+
150
+ return (
151
+ <motion.div
152
+ // `layout` animates sibling tiles into the gap when one is removed.
153
+ // Enter: spring-fast (0.08s) — the chip category per animation-guidelines.md.
154
+ // Exit: 0.06s linear — "exits should be slightly faster than enter",
155
+ // matches CheckboxGroup's hover-bg pattern.
156
+ layout
157
+ initial={{ opacity: 0, scale: 0.9 }}
158
+ animate={{ opacity: 1, scale: 1 }}
159
+ exit={{ opacity: 0, scale: 0.9, transition: spring.fast.exit }}
160
+ transition={spring.fast}
161
+ // `cursor-default` opts out of the parent's `cursor-text` so hovering
162
+ // a preview tile doesn't look like it'll land in the textarea.
163
+ className="relative shrink-0 cursor-default group/tile"
164
+ >
165
+ <FileThumbnail file={file} size={size} />
166
+ <Tooltip content="Remove" side="top">
167
+ <button
168
+ type="button"
169
+ onClick={(e) => {
170
+ e.stopPropagation();
171
+ onRemove();
172
+ }}
173
+ aria-label={`Remove ${file.name}`}
174
+ // Force the light-mode palette (dark circle + white X) regardless
175
+ // of theme — the close badge needs to read as a "delete affordance"
176
+ // over arbitrary image/PDF content, so it sits at a fixed contrast
177
+ // instead of flipping with the surrounding surface.
178
+ className="absolute top-1 right-1 w-5 h-5 rounded-full bg-neutral-900 text-white opacity-0 group-hover/tile:opacity-100 transition-opacity duration-80 flex items-center justify-center cursor-pointer outline-none focus-visible:opacity-100 focus-visible:ring-1 focus-visible:ring-[#6B97FF]"
179
+ >
180
+ <XIcon size={12} strokeWidth={2.5} />
181
+ </button>
182
+ </Tooltip>
183
+ </motion.div>
184
+ );
185
+ }
186
+
187
+ // ─── Queued message row ───────────────────────────────────────────────────
188
+ // A pending message in the queue: a recessed, draggable row that reads as
189
+ // "staged, not live". Double-click (or Enter/F2) edits it back into the
190
+ // composer; the hover-revealed × (or Delete) removes it; drag — or Alt+↑/↓ —
191
+ // reorders. Top of the list is next to dispatch.
192
+ interface QueuedRowProps {
193
+ item: QueuedMessage;
194
+ index: number;
195
+ total: number;
196
+ reduceMotion: boolean;
197
+ isTouch: boolean;
198
+ onEdit: (item: QueuedMessage) => void;
199
+ onRemove: (item: QueuedMessage) => void;
200
+ onMove: (item: QueuedMessage, dir: -1 | 1) => void;
201
+ }
202
+
203
+ function QueuedRow({
204
+ item,
205
+ index,
206
+ total,
207
+ reduceMotion,
208
+ isTouch,
209
+ onEdit,
210
+ onRemove,
211
+ onMove,
212
+ }: QueuedRowProps) {
213
+ const XIcon = useIcon("x");
214
+ const ImageIcon = useIcon("image");
215
+ const fileCount = item.files.length;
216
+ const label = item.text || `${fileCount} attachment${fileCount === 1 ? "" : "s"}`;
217
+
218
+ return (
219
+ <Reorder.Item
220
+ value={item}
221
+ layout
222
+ // Enter: spring-fast chip category. Exit slightly faster (0.06s linear),
223
+ // per animation-guidelines.md. Reduced-motion drops the scale.
224
+ initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97 }}
225
+ animate={{ opacity: 1, scale: 1 }}
226
+ exit={
227
+ reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97, transition: spring.fast.exit }
228
+ }
229
+ transition={spring.fast}
230
+ aria-label={`Queued message ${index + 1} of ${total}: ${label}`}
231
+ tabIndex={0}
232
+ onDoubleClick={() => onEdit(item)}
233
+ onKeyDown={(e) => {
234
+ if (e.key === "Enter" || e.key === "F2") {
235
+ e.preventDefault();
236
+ onEdit(item);
237
+ } else if (e.key === "Delete" || e.key === "Backspace") {
238
+ e.preventDefault();
239
+ onRemove(item);
240
+ } else if (e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
241
+ e.preventDefault();
242
+ onMove(item, e.key === "ArrowUp" ? -1 : 1);
243
+ }
244
+ }}
245
+ className={cn(
246
+ "group/qrow flex items-center gap-2 rounded-lg bg-muted px-2.5 py-1.5",
247
+ "text-[13px] text-foreground/85 select-none outline-none",
248
+ "cursor-grab active:cursor-grabbing",
249
+ "focus-visible:ring-1 focus-visible:ring-[#6B97FF]",
250
+ )}
251
+ style={{ fontVariationSettings: fontWeights.normal }}
252
+ >
253
+ {fileCount > 0 && (
254
+ <span className="flex shrink-0 items-center gap-0.5 text-muted-foreground">
255
+ <ImageIcon size={13} />
256
+ {item.text && <span className="tabular-nums">{fileCount}</span>}
257
+ </span>
258
+ )}
259
+ <span className="min-w-0 flex-1 truncate">{label}</span>
260
+ <Tooltip content="Remove" side="top">
261
+ <button
262
+ type="button"
263
+ // Stop the pointer-down from starting a Reorder drag, and the click
264
+ // from bubbling to the row's double-click/edit handler.
265
+ onPointerDown={(e) => e.stopPropagation()}
266
+ onClick={(e) => {
267
+ e.stopPropagation();
268
+ onRemove(item);
269
+ }}
270
+ aria-label={`Remove queued message: ${label}`}
271
+ className={cn(
272
+ "shrink-0 flex h-5 w-5 items-center justify-center rounded-full",
273
+ "text-muted-foreground hover:text-foreground hover:bg-hover",
274
+ // Hover devices reveal × on row-hover; touch has no hover, so keep
275
+ // it persistently visible there.
276
+ isTouch
277
+ ? "opacity-100"
278
+ : "opacity-0 group-hover/qrow:opacity-100 focus-visible:opacity-100",
279
+ "transition-opacity duration-80 cursor-pointer outline-none",
280
+ "focus-visible:ring-1 focus-visible:ring-[#6B97FF]",
281
+ )}
282
+ >
283
+ <XIcon size={13} strokeWidth={2.5} />
284
+ </button>
285
+ </Tooltip>
286
+ </Reorder.Item>
287
+ );
288
+ }
289
+
290
+ // ─── InputMessage ─────────────────────────────────────────────────────────
291
+
292
+ const InputMessage = forwardRef<HTMLDivElement, InputMessageProps>(
293
+ (
294
+ {
295
+ value,
296
+ onValueChange,
297
+ onSend,
298
+ placeholder = "Ask me anything…",
299
+ leftSlot,
300
+ rightSlot,
301
+ disabled,
302
+ minRows = 1,
303
+ maxRows = 8,
304
+ clickToFocus = true,
305
+ sendLabel = "Send",
306
+ files,
307
+ onFilesChange,
308
+ accept = DEFAULT_ACCEPT,
309
+ maxFiles,
310
+ filePreviewSize = 80,
311
+ textareaProps,
312
+ status,
313
+ onStop,
314
+ queue,
315
+ onQueueChange,
316
+ showQueue = true,
317
+ history = [],
318
+ className,
319
+ style,
320
+ ...props
321
+ },
322
+ ref,
323
+ ) => {
324
+ const shape = useShape();
325
+ const ArrowUpIcon = useIcon("arrow-up");
326
+ const reduceMotion = useReducedMotion() ?? false;
327
+ const isTouch = useIsTouch();
328
+
329
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
330
+ const fileInputRef = useRef<HTMLInputElement>(null);
331
+ const [focusVisible, setFocusVisible] = useState(false);
332
+ const [dragOver, setDragOver] = useState(false);
333
+ const [hovered, setHovered] = useState(false);
334
+
335
+ const filesArr = useMemo(() => files ?? [], [files]);
336
+ const supportsFiles = onFilesChange !== undefined;
337
+
338
+ // Queue is active only when both the status is controlled and a change
339
+ // handler is wired — same opt-in shape as `supportsFiles`.
340
+ const queueArr = useMemo(() => queue ?? [], [queue]);
341
+ // Always-current view of the queue, so enqueue/edit/remove/move read the
342
+ // latest value even if a handler closure is stale (e.g. two submits land
343
+ // before the controlled `queue` prop round-trips back).
344
+ const queueRef = useRef(queueArr);
345
+ queueRef.current = queueArr;
346
+ const supportsQueue = status !== undefined && onQueueChange !== undefined;
347
+ const streaming = status === "streaming";
348
+ const [liveMsg, setLiveMsg] = useState("");
349
+
350
+ // Sent-message history navigation (readline-style). `historyIndex` is null
351
+ // when not browsing; `draftBeforeHistory` stashes the in-progress text so
352
+ // ArrowDown past the newest entry restores it.
353
+ const [historyIndex, setHistoryIndex] = useState<number | null>(null);
354
+ const draftBeforeHistory = useRef("");
355
+
356
+ useIsoLayoutEffect(() => {
357
+ const el = textareaRef.current;
358
+ if (!el) return;
359
+ el.style.height = "auto";
360
+ const computed = getComputedStyle(el);
361
+ const lineHeight = parseFloat(computed.lineHeight);
362
+ const measuredLineHeight = Number.isNaN(lineHeight) ? 20 : lineHeight;
363
+ const min = measuredLineHeight * minRows;
364
+ const max = measuredLineHeight * maxRows;
365
+ const next = Math.min(Math.max(el.scrollHeight, min), max);
366
+ el.style.height = `${next}px`;
367
+ el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
368
+ }, [value, minRows, maxRows]);
369
+
370
+ const trimmed = value.trim();
371
+ const canSend = !disabled && (trimmed.length > 0 || filesArr.length > 0);
372
+
373
+ // Edge = the box-shadow's 1px ring, recoloured in place per state so the
374
+ // stroke gains contrast without ever appearing to thicken (no second
375
+ // border band layered beside it). The drop (`0 1px 1px`) is kept so the
376
+ // composer holds its lift across states. Applied inline (not via a Tailwind
377
+ // `shadow-*` utility, which mangles multi-layer arbitrary values) with the
378
+ // precedence drag > focus > hover; when none are active, the className's
379
+ // `shadow-surface-2` supplies the resting edge.
380
+ const EDGE_DROP = "0 1px 1px -0.5px var(--shadow-color)";
381
+ const edgeShadow = dragOver
382
+ ? `0 0 0 1px #6B97FF, ${EDGE_DROP}`
383
+ : focusVisible
384
+ ? `0 0 0 1px color-mix(in oklab, var(--foreground) 20%, transparent), ${EDGE_DROP}`
385
+ : hovered && clickToFocus && !disabled
386
+ ? `0 0 0 1px var(--border), ${EDGE_DROP}`
387
+ : undefined;
388
+
389
+ const handleSend = useCallback(() => {
390
+ if (!canSend) return;
391
+ setHistoryIndex(null);
392
+ // While the assistant is streaming, a submit enqueues instead of sending:
393
+ // snapshot the draft (text + currently-attached files) into a queue item,
394
+ // then clear the composer and keep focus.
395
+ if (streaming && supportsQueue) {
396
+ const item: QueuedMessage = {
397
+ id: crypto.randomUUID(),
398
+ text: trimmed,
399
+ files: filesArr,
400
+ };
401
+ onQueueChange?.([...queueRef.current, item]);
402
+ onValueChange("");
403
+ if (supportsFiles) onFilesChange?.([]);
404
+ requestAnimationFrame(() => textareaRef.current?.focus());
405
+ return;
406
+ }
407
+ onSend?.(trimmed, filesArr);
408
+ }, [
409
+ canSend,
410
+ streaming,
411
+ supportsQueue,
412
+ onSend,
413
+ trimmed,
414
+ filesArr,
415
+ onQueueChange,
416
+ onValueChange,
417
+ supportsFiles,
418
+ onFilesChange,
419
+ ]);
420
+
421
+ const handleStop = useCallback(() => onStop?.(), [onStop]);
422
+
423
+ // Auto-dispatch: on the streaming → idle edge (whether the response
424
+ // finished on its own or the user pressed Stop), fire the head of the
425
+ // queue and drop it. The consumer is expected to set status back to
426
+ // "streaming" inside onSend, which re-arms this for the next item.
427
+ const prevStatusRef = useRef(status);
428
+ useEffect(() => {
429
+ const prev = prevStatusRef.current;
430
+ prevStatusRef.current = status;
431
+ if (!supportsQueue) return;
432
+ if (prev === "streaming" && status === "idle" && queueArr.length > 0) {
433
+ const [next, ...rest] = queueArr;
434
+ onQueueChange?.(rest);
435
+ onSend?.(next.text, next.files, { queuedId: next.id });
436
+ setLiveMsg(`Message sent.${rest.length ? ` ${rest.length} still queued.` : ""}`);
437
+ }
438
+ }, [status, supportsQueue, queueArr, onQueueChange, onSend]);
439
+
440
+ // ── Queue item actions ────────────────────────────────────────────
441
+ const editQueued = useCallback(
442
+ (item: QueuedMessage) => {
443
+ if (!supportsQueue) return;
444
+ // Silent replace: pull the item out of the queue into the composer,
445
+ // overwriting any current draft. Re-sending re-queues it to the end.
446
+ setHistoryIndex(null);
447
+ onValueChange(item.text);
448
+ if (supportsFiles) {
449
+ onFilesChange?.(maxFiles != null ? item.files.slice(0, maxFiles) : item.files);
450
+ }
451
+ onQueueChange?.(queueRef.current.filter((q) => q.id !== item.id));
452
+ requestAnimationFrame(() => {
453
+ const el = textareaRef.current;
454
+ if (!el) return;
455
+ el.focus();
456
+ el.setSelectionRange(el.value.length, el.value.length);
457
+ });
458
+ },
459
+ [supportsQueue, supportsFiles, onValueChange, onFilesChange, maxFiles, onQueueChange],
460
+ );
461
+
462
+ const removeQueued = useCallback(
463
+ (item: QueuedMessage) => onQueueChange?.(queueRef.current.filter((q) => q.id !== item.id)),
464
+ [onQueueChange],
465
+ );
466
+
467
+ const moveQueued = useCallback(
468
+ (item: QueuedMessage, dir: -1 | 1) => {
469
+ const cur = queueRef.current;
470
+ const i = cur.findIndex((q) => q.id === item.id);
471
+ const j = i + dir;
472
+ if (i < 0 || j < 0 || j >= cur.length) return;
473
+ const next = [...cur];
474
+ [next[i], next[j]] = [next[j], next[i]];
475
+ onQueueChange?.(next);
476
+ },
477
+ [onQueueChange],
478
+ );
479
+
480
+ // Send button morph: Stop (streaming + empty draft) → Queue (streaming +
481
+ // draft) → Send (idle). Send and Queue share the arrow-up glyph; only the
482
+ // Stop⇄arrow swap animates.
483
+ const buttonMode: "send" | "queue" | "stop" = !streaming
484
+ ? "send"
485
+ : canSend && supportsQueue
486
+ ? "queue"
487
+ : onStop
488
+ ? "stop"
489
+ : "send";
490
+ const buttonLabel =
491
+ buttonMode === "stop" ? "Stop" : buttonMode === "queue" ? "Queue message" : sendLabel;
492
+
493
+ const setCaretEnd = useCallback(() => {
494
+ requestAnimationFrame(() => {
495
+ const el = textareaRef.current;
496
+ if (el) el.setSelectionRange(el.value.length, el.value.length);
497
+ });
498
+ }, []);
499
+
500
+ const handleKeyDown = useCallback(
501
+ (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
502
+ if (e.nativeEvent.isComposing) return;
503
+
504
+ // Readline-style history. Only plain ArrowUp/ArrowDown navigate (no
505
+ // modifiers), and only when the caret is on the first/last line so
506
+ // multi-line editing still works normally.
507
+ if (
508
+ history.length > 0 &&
509
+ (e.key === "ArrowUp" || e.key === "ArrowDown") &&
510
+ !e.shiftKey &&
511
+ !e.altKey &&
512
+ !e.metaKey &&
513
+ !e.ctrlKey
514
+ ) {
515
+ const el = e.currentTarget;
516
+ const caret = el.selectionStart ?? 0;
517
+ const end = el.selectionEnd ?? caret;
518
+ if (e.key === "ArrowUp" && !value.slice(0, caret).includes("\n")) {
519
+ const start = historyIndex == null ? history.length : historyIndex;
520
+ if (start > 0) {
521
+ e.preventDefault();
522
+ if (historyIndex == null) draftBeforeHistory.current = value;
523
+ const ni = start - 1;
524
+ setHistoryIndex(ni);
525
+ onValueChange(history[ni]);
526
+ setCaretEnd();
527
+ }
528
+ return;
529
+ }
530
+ if (e.key === "ArrowDown" && historyIndex != null && !value.slice(end).includes("\n")) {
531
+ e.preventDefault();
532
+ const ni = historyIndex + 1;
533
+ if (ni >= history.length) {
534
+ setHistoryIndex(null);
535
+ onValueChange(draftBeforeHistory.current);
536
+ } else {
537
+ setHistoryIndex(ni);
538
+ onValueChange(history[ni]);
539
+ }
540
+ setCaretEnd();
541
+ return;
542
+ }
543
+ }
544
+
545
+ if (e.key === "Enter" && !e.shiftKey) {
546
+ e.preventDefault();
547
+ handleSend();
548
+ }
549
+ },
550
+ [history, value, historyIndex, onValueChange, setCaretEnd, handleSend],
551
+ );
552
+
553
+ const handleContainerMouseDown = useCallback(
554
+ (e: React.MouseEvent<HTMLDivElement>) => {
555
+ if (!clickToFocus || disabled) return;
556
+ const target = e.target as HTMLElement;
557
+ if (target === textareaRef.current) return;
558
+ if (
559
+ target.closest(
560
+ 'button, a, input, select, textarea, [contenteditable], [role="button"], [data-im-queue]',
561
+ )
562
+ ) {
563
+ return;
564
+ }
565
+ e.preventDefault();
566
+ textareaRef.current?.focus();
567
+ },
568
+ [clickToFocus, disabled],
569
+ );
570
+
571
+ // ── File helpers ──────────────────────────────────────────────────
572
+ const acceptTokens = useMemo(
573
+ () =>
574
+ accept
575
+ .split(",")
576
+ .map((s) => s.trim())
577
+ .filter(Boolean),
578
+ [accept],
579
+ );
580
+
581
+ const matchesAccept = useCallback(
582
+ (file: File) =>
583
+ acceptTokens.some((token) => {
584
+ if (token.endsWith("/*")) return file.type.startsWith(token.slice(0, -1));
585
+ if (token.startsWith(".")) return file.name.toLowerCase().endsWith(token.toLowerCase());
586
+ return file.type === token;
587
+ }),
588
+ [acceptTokens],
589
+ );
590
+
591
+ const addFiles = useCallback(
592
+ (incoming: File[]) => {
593
+ if (!onFilesChange) return;
594
+ // Identity key for dedup: name + size + lastModified is unique enough
595
+ // to catch "user dropped the same file twice" without false positives
596
+ // on legitimately distinct files (different bytes ⇒ different size).
597
+ const fingerprint = (f: File) => `${f.name}-${f.size}-${f.lastModified}`;
598
+ const existing = new Set(filesArr.map(fingerprint));
599
+ const accepted: File[] = [];
600
+ for (const f of incoming) {
601
+ if (!matchesAccept(f)) continue;
602
+ const fp = fingerprint(f);
603
+ if (existing.has(fp)) continue;
604
+ existing.add(fp);
605
+ accepted.push(f);
606
+ }
607
+ if (!accepted.length) return;
608
+ const next = [...filesArr, ...accepted];
609
+ onFilesChange(maxFiles != null ? next.slice(0, maxFiles) : next);
610
+ },
611
+ [onFilesChange, filesArr, matchesAccept, maxFiles],
612
+ );
613
+
614
+ const removeFile = useCallback(
615
+ (idx: number) => {
616
+ if (!onFilesChange) return;
617
+ onFilesChange(filesArr.filter((_, i) => i !== idx));
618
+ },
619
+ [onFilesChange, filesArr],
620
+ );
621
+
622
+ const openFilePicker = useCallback(
623
+ (overrideAccept?: string) => {
624
+ const el = fileInputRef.current;
625
+ if (!el) return;
626
+ // Temporarily narrow `accept` for this invocation (e.g. "image/*").
627
+ // Reset after the click so subsequent native invocations still honor
628
+ // the component-level accept.
629
+ if (overrideAccept) {
630
+ el.accept = overrideAccept;
631
+ el.click();
632
+ // Restore on next tick — the picker dialog reads `accept` synchronously.
633
+ queueMicrotask(() => {
634
+ if (fileInputRef.current) fileInputRef.current.accept = accept;
635
+ });
636
+ return;
637
+ }
638
+ el.click();
639
+ },
640
+ [accept],
641
+ );
642
+
643
+ // ── Slot rendering ────────────────────────────────────────────────
644
+ const slotCtx = useMemo<InputMessageSlotContext>(
645
+ () => ({ openFilePicker, files: filesArr }),
646
+ [openFilePicker, filesArr],
647
+ );
648
+ const leftContent = typeof leftSlot === "function" ? leftSlot(slotCtx) : leftSlot;
649
+ const rightContent = typeof rightSlot === "function" ? rightSlot(slotCtx) : rightSlot;
650
+
651
+ // ── Drag-and-drop ────────────────────────────────────────────────
652
+ const handleDragOver = useCallback(
653
+ (e: ReactDragEvent<HTMLDivElement>) => {
654
+ if (!supportsFiles || disabled) return;
655
+ // Only treat as a file drag — text/HTML drags shouldn't trigger.
656
+ if (!Array.from(e.dataTransfer.types).includes("Files")) return;
657
+ e.preventDefault();
658
+ e.dataTransfer.dropEffect = "copy";
659
+ setDragOver(true);
660
+ },
661
+ [supportsFiles, disabled],
662
+ );
663
+
664
+ const handleDragLeave = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
665
+ const wrapper = e.currentTarget;
666
+ const next = e.relatedTarget as Node | null;
667
+ if (next && wrapper.contains(next)) return;
668
+ setDragOver(false);
669
+ }, []);
670
+
671
+ const handleDrop = useCallback(
672
+ (e: ReactDragEvent<HTMLDivElement>) => {
673
+ e.preventDefault();
674
+ setDragOver(false);
675
+ if (!supportsFiles || disabled) return;
676
+ addFiles(Array.from(e.dataTransfer.files));
677
+ },
678
+ [supportsFiles, disabled, addFiles],
679
+ );
680
+
681
+ const handleFileInputChange = useCallback(
682
+ (e: ChangeEvent<HTMLInputElement>) => {
683
+ if (!e.target.files) return;
684
+ addFiles(Array.from(e.target.files));
685
+ e.target.value = ""; // Allow re-selecting the same file.
686
+ },
687
+ [addFiles],
688
+ );
689
+
690
+ return (
691
+ <div
692
+ ref={ref}
693
+ onMouseDown={handleContainerMouseDown}
694
+ onDragOver={handleDragOver}
695
+ onDragLeave={handleDragLeave}
696
+ onDrop={handleDrop}
697
+ className={cn(
698
+ // The edge is the box-shadow's hairline ring (from surface-2), not a
699
+ // border. State changes recolor that same 1px ring in place rather
700
+ // than layering a second colored border beside it — so hover / focus
701
+ // bump *contrast* without ever appearing to thicken the stroke.
702
+ "flex flex-col gap-1 p-2 transition-[box-shadow,color] duration-80",
703
+ surfaceClasses(2, 2),
704
+ shape.container,
705
+ clickToFocus && !disabled && "cursor-text",
706
+ disabled && "opacity-50 pointer-events-none",
707
+ className,
708
+ )}
709
+ style={edgeShadow ? { boxShadow: edgeShadow, ...style } : style}
710
+ onMouseEnter={() => setHovered(true)}
711
+ onMouseLeave={() => setHovered(false)}
712
+ {...props}
713
+ >
714
+ <SurfaceProvider value={2}>
715
+ {supportsFiles && (
716
+ <input
717
+ ref={fileInputRef}
718
+ type="file"
719
+ accept={accept}
720
+ multiple={maxFiles == null || maxFiles > 1}
721
+ className="hidden"
722
+ onChange={handleFileInputChange}
723
+ aria-hidden="true"
724
+ tabIndex={-1}
725
+ />
726
+ )}
727
+
728
+ {/* Attached files preview row — sits above the textarea.
729
+ The outer motion.div animates the row's height (collapsing the
730
+ whole component height) when files appear / disappear.
731
+ The inner `mode="popLayout"` AnimatePresence pulls a removing
732
+ tile out of layout flow so siblings can slide into the gap
733
+ without fighting its exit anim. Keys are purely file-identity
734
+ (no index) so removing the first file doesn't re-key — and
735
+ remount — every surviving sibling. */}
736
+ <AnimatePresence initial={false}>
737
+ {filesArr.length > 0 && (
738
+ <motion.div
739
+ key="preview-row"
740
+ initial={{ height: 0, opacity: 0 }}
741
+ animate={{ height: "auto", opacity: 1 }}
742
+ exit={{ height: 0, opacity: 0 }}
743
+ transition={{ ...spring.moderate, bounce: 0 }}
744
+ className="overflow-hidden"
745
+ >
746
+ <div className="flex flex-wrap gap-2 pb-1">
747
+ <AnimatePresence initial={false} mode="popLayout">
748
+ {filesArr.map((file, i) => (
749
+ <FilePreviewTile
750
+ key={`${file.name}-${file.size}-${file.lastModified}`}
751
+ file={file}
752
+ onRemove={() => removeFile(i)}
753
+ size={filePreviewSize}
754
+ />
755
+ ))}
756
+ </AnimatePresence>
757
+ </div>
758
+ </motion.div>
759
+ )}
760
+ </AnimatePresence>
761
+
762
+ {/* Queued messages — reorderable rows above the textarea. The outer
763
+ motion.div collapses the region height when the queue empties;
764
+ the Reorder.Group handles drag-reorder (top = next to dispatch)
765
+ and AnimatePresence handles per-row enter/exit. */}
766
+ {supportsQueue && showQueue && (
767
+ <AnimatePresence initial={false}>
768
+ {queueArr.length > 0 && (
769
+ <motion.div
770
+ key="queue-row"
771
+ initial={{ height: 0, opacity: 0 }}
772
+ animate={{ height: "auto", opacity: 1 }}
773
+ exit={{ height: 0, opacity: 0 }}
774
+ transition={{ ...spring.moderate, bounce: 0 }}
775
+ className="overflow-hidden"
776
+ >
777
+ <Reorder.Group
778
+ axis="y"
779
+ values={queueArr}
780
+ onReorder={(next) => onQueueChange?.(next)}
781
+ data-im-queue
782
+ className="flex flex-col gap-1 pb-1"
783
+ >
784
+ <AnimatePresence initial={false}>
785
+ {queueArr.map((item, i) => (
786
+ <QueuedRow
787
+ key={item.id}
788
+ item={item}
789
+ index={i}
790
+ total={queueArr.length}
791
+ reduceMotion={reduceMotion}
792
+ isTouch={isTouch}
793
+ onEdit={editQueued}
794
+ onRemove={removeQueued}
795
+ onMove={moveQueued}
796
+ />
797
+ ))}
798
+ </AnimatePresence>
799
+ </Reorder.Group>
800
+ </motion.div>
801
+ )}
802
+ </AnimatePresence>
803
+ )}
804
+
805
+ <textarea
806
+ ref={textareaRef}
807
+ value={value}
808
+ onChange={(e) => {
809
+ // Real typing exits history mode (recall sets the value
810
+ // programmatically, which doesn't fire onChange).
811
+ setHistoryIndex(null);
812
+ onValueChange(e.target.value);
813
+ }}
814
+ onKeyDown={handleKeyDown}
815
+ onFocus={(e) => {
816
+ if (e.target.matches(":focus-visible")) setFocusVisible(true);
817
+ }}
818
+ onBlur={() => setFocusVisible(false)}
819
+ placeholder={dragOver && supportsFiles ? "Drop files here to add to chat" : placeholder}
820
+ disabled={disabled}
821
+ rows={minRows}
822
+ aria-label={textareaProps?.["aria-label"] ?? "Message"}
823
+ className={cn(
824
+ "w-full resize-none bg-transparent outline-none",
825
+ "text-[14px] leading-5 text-foreground placeholder:text-muted-foreground",
826
+ "px-2 py-2",
827
+ )}
828
+ style={{ fontVariationSettings: fontWeights.normal }}
829
+ {...textareaProps}
830
+ />
831
+ <div className="flex items-center justify-between gap-2">
832
+ <div className="flex items-center gap-1.5 min-w-0">{leftContent}</div>
833
+ <div className="flex items-center gap-1.5 shrink-0">
834
+ {rightContent}
835
+ <Button
836
+ type="button"
837
+ variant="primary"
838
+ size="icon-sm"
839
+ onClick={buttonMode === "stop" ? handleStop : handleSend}
840
+ disabled={buttonMode === "stop" ? disabled : !canSend}
841
+ aria-label={buttonLabel}
842
+ >
843
+ <AnimatePresence mode="wait" initial={false}>
844
+ <motion.span
845
+ key={buttonMode === "stop" ? "stop" : "arrow"}
846
+ initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
847
+ animate={{ opacity: 1, scale: 1 }}
848
+ exit={
849
+ reduceMotion
850
+ ? { opacity: 0 }
851
+ : { opacity: 0, scale: 0.6, transition: spring.fast.exit }
852
+ }
853
+ transition={spring.fast}
854
+ className="flex items-center justify-center leading-none"
855
+ >
856
+ {buttonMode === "stop" ? (
857
+ <span className="h-3 w-3 rounded-[3px] bg-current" />
858
+ ) : (
859
+ // Override icon-sm's small 14px svg — the send glyph reads
860
+ // better a touch larger. `size` matches the attribute to
861
+ // the CSS so the svg box stays centered.
862
+ <ArrowUpIcon size={19} className="block !h-[19px] !w-[19px]" />
863
+ )}
864
+ </motion.span>
865
+ </AnimatePresence>
866
+ </Button>
867
+ </div>
868
+ </div>
869
+ {/* Politely announces auto-dispatch of queued messages. */}
870
+ <span className="sr-only" role="status" aria-live="polite">
871
+ {liveMsg}
872
+ </span>
873
+ </SurfaceProvider>
874
+ </div>
875
+ );
876
+ },
877
+ );
878
+
879
+ InputMessage.displayName = "InputMessage";
880
+
881
+ export { InputMessage };
882
+ export type { InputMessageProps, InputMessageSlotContext, QueuedMessage };
883
+ export default InputMessage;