@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.
- package/README.md +4 -0
- package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
- package/dist/chunk-RI6QJR2L.js.map +1 -0
- package/dist/index.d.ts +789 -115
- package/dist/index.js +10404 -1099
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +17 -0
- package/dist/tokens.d.ts +3 -0
- package/dist/tokens.js +1 -1
- package/llms.txt +18 -4
- package/package.json +19 -1
- package/src/components/accordion.tsx +746 -58
- package/src/components/alert-dialog.tsx +11 -5
- package/src/components/ask-user-questions.tsx +1366 -0
- package/src/components/badge.tsx +105 -24
- package/src/components/button.tsx +207 -38
- package/src/components/card.tsx +3 -2
- package/src/components/chat-message.tsx +116 -0
- package/src/components/checkbox-group.tsx +403 -0
- package/src/components/checkbox.tsx +6 -3
- package/src/components/color-input.tsx +3 -1
- package/src/components/color-picker.tsx +1992 -80
- package/src/components/dialog.tsx +141 -81
- package/src/components/dropdown-menu.tsx +21 -13
- package/src/components/dropdown.tsx +245 -0
- package/src/components/file-thumbnail.tsx +156 -0
- package/src/components/hover-card.tsx +2 -0
- package/src/components/input-copy.tsx +307 -0
- package/src/components/input-group.tsx +303 -108
- package/src/components/input-message.tsx +883 -0
- package/src/components/input.tsx +6 -3
- package/src/components/menu-item.tsx +156 -0
- package/src/components/mobile-drawer.tsx +50 -0
- package/src/components/nav-item.tsx +43 -0
- package/src/components/nav-menu.tsx +47 -0
- package/src/components/popover.tsx +3 -1
- package/src/components/radio-group.tsx +381 -35
- package/src/components/scroll-area.tsx +9 -2
- package/src/components/select.tsx +708 -121
- package/src/components/sheet.tsx +15 -6
- package/src/components/slider.tsx +1583 -20
- package/src/components/status-badge.tsx +2 -0
- package/src/components/switch.tsx +245 -28
- package/src/components/table.tsx +191 -77
- package/src/components/tabs-subtle.tsx +411 -0
- package/src/components/tabs.tsx +459 -49
- package/src/components/textarea.tsx +7 -3
- package/src/components/thinking-indicator.tsx +106 -0
- package/src/components/thinking-steps.tsx +382 -0
- package/src/components/tooltip.tsx +158 -33
- package/src/hooks/use-merge-split.tsx +359 -0
- package/src/hooks/use-proximity-hover.ts +208 -0
- package/src/index.ts +14 -0
- package/src/lib/elevated.tsx +52 -0
- package/src/lib/font-weight.ts +18 -0
- package/src/lib/icon-context.tsx +98 -0
- package/src/lib/icon-map.tsx +669 -0
- package/src/lib/interaction.ts +102 -0
- package/src/lib/scroll-fade.tsx +241 -0
- package/src/lib/shape-context.tsx +121 -0
- package/src/lib/springs.ts +30 -0
- package/src/lib/surface-classes.ts +43 -0
- package/src/lib/surface-context.tsx +17 -0
- package/src/tokens/tokens.ts +3 -0
- 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;
|