@springbrand/message-panel 0.1.2 → 0.1.3-alpha.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 (99) hide show
  1. package/demo/camel-chat-showcase.tsx +1265 -0
  2. package/demo/fixtures.ts +292 -0
  3. package/demo/message-panel-chat-showcase.tsx +250 -0
  4. package/demo/message-panel-gallery.tsx +389 -0
  5. package/package.json +6 -16
  6. package/src/adapters/ai-sdk.ts +32 -0
  7. package/src/camel/camel-chat-messages.tsx +1435 -0
  8. package/src/camel/camel-model-select.tsx +88 -0
  9. package/src/camel/camel-plan-bar.tsx +115 -0
  10. package/src/camel/camel-prompt-input.tsx +547 -0
  11. package/src/camel/camel-tool-presentation.tsx +1270 -0
  12. package/src/camel/camel-turn.ts +334 -0
  13. package/src/camel/turn-summary-bar.tsx +81 -0
  14. package/src/chat-summary-panel.tsx +229 -0
  15. package/src/composer/chat-composer.tsx +216 -0
  16. package/src/composer/composer-attachments.tsx +138 -0
  17. package/src/composer/composer-trigger-popover.tsx +236 -0
  18. package/src/composer/composer.tsx +425 -0
  19. package/src/composer/drop-controller.ts +80 -0
  20. package/src/composer/types.ts +79 -0
  21. package/src/contracts.ts +138 -0
  22. package/src/conversation.tsx +76 -0
  23. package/src/internal/cn.ts +3 -0
  24. package/src/message-panel.tsx +368 -0
  25. package/src/message.tsx +254 -0
  26. package/src/parts/index.tsx +1149 -0
  27. package/src/parts/kind.ts +67 -0
  28. package/src/parts/plan.ts +64 -0
  29. package/src/parts/primitives.tsx +439 -0
  30. package/src/registry.ts +44 -0
  31. package/src/status.tsx +132 -0
  32. package/dist/demo/camel-chat-showcase.d.ts +0 -1
  33. package/dist/demo/camel-chat-showcase.js +0 -1007
  34. package/dist/demo/fixtures.d.ts +0 -32
  35. package/dist/demo/fixtures.js +0 -250
  36. package/dist/demo/index.js +0 -4
  37. package/dist/demo/message-panel-chat-showcase.d.ts +0 -1
  38. package/dist/demo/message-panel-chat-showcase.js +0 -114
  39. package/dist/demo/message-panel-gallery.d.ts +0 -1
  40. package/dist/demo/message-panel-gallery.js +0 -130
  41. package/dist/src/adapters/ai-sdk.d.ts +0 -4
  42. package/dist/src/adapters/ai-sdk.js +0 -25
  43. package/dist/src/camel/camel-chat-messages.d.ts +0 -44
  44. package/dist/src/camel/camel-chat-messages.js +0 -421
  45. package/dist/src/camel/camel-model-select.d.ts +0 -13
  46. package/dist/src/camel/camel-model-select.js +0 -7
  47. package/dist/src/camel/camel-plan-bar.d.ts +0 -5
  48. package/dist/src/camel/camel-plan-bar.js +0 -24
  49. package/dist/src/camel/camel-prompt-input.d.ts +0 -32
  50. package/dist/src/camel/camel-prompt-input.js +0 -182
  51. package/dist/src/camel/camel-tool-presentation.d.ts +0 -21
  52. package/dist/src/camel/camel-tool-presentation.js +0 -784
  53. package/dist/src/camel/camel-turn.d.ts +0 -57
  54. package/dist/src/camel/camel-turn.js +0 -203
  55. package/dist/src/camel/index.js +0 -7
  56. package/dist/src/camel/turn-summary-bar.d.ts +0 -8
  57. package/dist/src/camel/turn-summary-bar.js +0 -12
  58. package/dist/src/chat-summary-panel.d.ts +0 -22
  59. package/dist/src/chat-summary-panel.js +0 -26
  60. package/dist/src/composer/chat-composer.d.ts +0 -4
  61. package/dist/src/composer/chat-composer.js +0 -90
  62. package/dist/src/composer/composer-attachments.d.ts +0 -13
  63. package/dist/src/composer/composer-attachments.js +0 -47
  64. package/dist/src/composer/composer-trigger-popover.d.ts +0 -31
  65. package/dist/src/composer/composer-trigger-popover.js +0 -29
  66. package/dist/src/composer/composer.d.ts +0 -15
  67. package/dist/src/composer/composer.js +0 -180
  68. package/dist/src/composer/drop-controller.d.ts +0 -8
  69. package/dist/src/composer/drop-controller.js +0 -69
  70. package/dist/src/composer/index.js +0 -6
  71. package/dist/src/composer/types.d.ts +0 -62
  72. package/dist/src/composer/types.js +0 -1
  73. package/dist/src/contracts.d.ts +0 -94
  74. package/dist/src/contracts.js +0 -1
  75. package/dist/src/conversation.d.ts +0 -13
  76. package/dist/src/conversation.js +0 -18
  77. package/dist/src/index.js +0 -12
  78. package/dist/src/internal/cn.d.ts +0 -1
  79. package/dist/src/internal/cn.js +0 -3
  80. package/dist/src/message-panel.d.ts +0 -4
  81. package/dist/src/message-panel.js +0 -144
  82. package/dist/src/message.d.ts +0 -36
  83. package/dist/src/message.js +0 -83
  84. package/dist/src/parts/index.d.ts +0 -187
  85. package/dist/src/parts/index.js +0 -332
  86. package/dist/src/parts/kind.d.ts +0 -3
  87. package/dist/src/parts/kind.js +0 -69
  88. package/dist/src/parts/plan.d.ts +0 -10
  89. package/dist/src/parts/plan.js +0 -40
  90. package/dist/src/parts/primitives.d.ts +0 -70
  91. package/dist/src/parts/primitives.js +0 -153
  92. package/dist/src/registry.d.ts +0 -4
  93. package/dist/src/registry.js +0 -31
  94. package/dist/src/status.d.ts +0 -26
  95. package/dist/src/status.js +0 -74
  96. /package/{dist/demo/index.d.ts → demo/index.ts} +0 -0
  97. /package/{dist/src/camel/index.d.ts → src/camel/index.ts} +0 -0
  98. /package/{dist/src/composer/index.d.ts → src/composer/index.ts} +0 -0
  99. /package/{dist/src/index.d.ts → src/index.ts} +0 -0
@@ -0,0 +1,425 @@
1
+ import {
2
+ ArrowUpIcon,
3
+ Loader2Icon,
4
+ PlusIcon,
5
+ SquareIcon,
6
+ } from "lucide-react";
7
+ import {
8
+ forwardRef,
9
+ useCallback,
10
+ useImperativeHandle,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ type ButtonHTMLAttributes,
15
+ type ClipboardEvent,
16
+ type DragEvent,
17
+ type HTMLAttributes,
18
+ type KeyboardEvent,
19
+ } from "react";
20
+ import { cn } from "../internal/cn";
21
+ import {
22
+ AttachmentPreview,
23
+ AttachmentRemove,
24
+ AttachmentReorder,
25
+ AttachmentRetry,
26
+ Attachments,
27
+ } from "./composer-attachments";
28
+ import { useComposerDrop } from "./drop-controller";
29
+ import type {
30
+ ComposerAttachmentView,
31
+ ComposerProps,
32
+ } from "./types";
33
+
34
+ export const ComposerHeader = forwardRef<
35
+ HTMLDivElement,
36
+ HTMLAttributes<HTMLDivElement>
37
+ >(({ className, ...props }, ref) => (
38
+ <div ref={ref} className={cn("sb-composer__header", className)} {...props} />
39
+ ));
40
+ ComposerHeader.displayName = "ComposerHeader";
41
+
42
+ export const ComposerBody = forwardRef<
43
+ HTMLDivElement,
44
+ HTMLAttributes<HTMLDivElement>
45
+ >(({ className, ...props }, ref) => (
46
+ <div ref={ref} className={cn("sb-composer__body", className)} {...props} />
47
+ ));
48
+ ComposerBody.displayName = "ComposerBody";
49
+
50
+ export const ComposerFooter = forwardRef<
51
+ HTMLDivElement,
52
+ HTMLAttributes<HTMLDivElement>
53
+ >(({ className, ...props }, ref) => (
54
+ <div ref={ref} className={cn("sb-composer__footer", className)} {...props} />
55
+ ));
56
+ ComposerFooter.displayName = "ComposerFooter";
57
+
58
+ export const ComposerTools = forwardRef<
59
+ HTMLDivElement,
60
+ HTMLAttributes<HTMLDivElement>
61
+ >(({ className, ...props }, ref) => (
62
+ <div ref={ref} className={cn("sb-composer__tools", className)} {...props} />
63
+ ));
64
+ ComposerTools.displayName = "ComposerTools";
65
+
66
+ export const ComposerSubmit = forwardRef<
67
+ HTMLButtonElement,
68
+ ButtonHTMLAttributes<HTMLButtonElement> & {
69
+ status?: "idle" | "submitted" | "streaming";
70
+ }
71
+ >(({ className, status = "idle", children, ...props }, ref) => (
72
+ <button
73
+ ref={ref}
74
+ type="button"
75
+ className={cn("sb-composer__submit", className)}
76
+ {...props}
77
+ >
78
+ {children ??
79
+ (status === "submitted" ? (
80
+ <Loader2Icon size={16} className="sb-spin" />
81
+ ) : status === "streaming" ? (
82
+ <SquareIcon size={15} />
83
+ ) : (
84
+ <ArrowUpIcon size={17} />
85
+ ))}
86
+ </button>
87
+ ));
88
+ ComposerSubmit.displayName = "ComposerSubmit";
89
+
90
+ export function DropOverlay({
91
+ label = "松开以添加文件",
92
+ }: {
93
+ label?: string;
94
+ }) {
95
+ return (
96
+ <div className="sb-composer__drop" role="status">
97
+ <PlusIcon size={20} />
98
+ <span>{label}</span>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ const ATTACHMENT_DRAG_TYPE = "application/x-sb-message-panel-attachment";
104
+
105
+ function defaultAttachment<T>(attachment: T, index: number): ComposerAttachmentView {
106
+ const record =
107
+ attachment != null && typeof attachment === "object"
108
+ ? (attachment as Record<string, unknown>)
109
+ : {};
110
+ return {
111
+ id: String(record.id ?? `attachment-${index}`),
112
+ name: String(record.name ?? record.filename ?? "附件"),
113
+ mediaType:
114
+ typeof record.mediaType === "string" ? record.mediaType : undefined,
115
+ size: typeof record.size === "number" ? record.size : undefined,
116
+ url: typeof record.url === "string" ? record.url : undefined,
117
+ status:
118
+ record.status === "uploading" || record.status === "error"
119
+ ? record.status
120
+ : "ready",
121
+ note: typeof record.note === "string" ? record.note : undefined,
122
+ };
123
+ }
124
+
125
+ function matchesAccept(file: File, accept?: string): boolean {
126
+ if (!accept?.trim()) return true;
127
+ return accept
128
+ .split(",")
129
+ .map((value) => value.trim().toLowerCase())
130
+ .some((rule) => {
131
+ if (rule.startsWith(".")) return file.name.toLowerCase().endsWith(rule);
132
+ if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1));
133
+ return file.type.toLowerCase() === rule;
134
+ });
135
+ }
136
+
137
+ function ComposerInner<TAttachment>(
138
+ {
139
+ value,
140
+ defaultValue = "",
141
+ onValueChange,
142
+ status,
143
+ attachments = [],
144
+ getAttachment = defaultAttachment,
145
+ dropScope = "composer",
146
+ dropTarget,
147
+ accept,
148
+ multiple = true,
149
+ maxFiles,
150
+ maxFileSize,
151
+ onFilesAdded,
152
+ onAttachmentRemove,
153
+ onAttachmentRetry,
154
+ onAttachmentReorder,
155
+ onValidationError,
156
+ onSubmit,
157
+ onStop,
158
+ placeholder = "问点什么…",
159
+ tools,
160
+ textareaProps,
161
+ textareaRef: externalTextareaRef,
162
+ className,
163
+ ...props
164
+ }: ComposerProps<TAttachment>,
165
+ forwardedRef: React.ForwardedRef<HTMLDivElement>,
166
+ ) {
167
+ const [internalValue, setInternalValue] = useState(defaultValue);
168
+ const resolvedValue = value ?? internalValue;
169
+ const rootRef = useRef<HTMLDivElement | null>(null);
170
+ const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
171
+ const inputRef = useRef<HTMLInputElement | null>(null);
172
+ useImperativeHandle(forwardedRef, () => rootRef.current as HTMLDivElement);
173
+ useImperativeHandle(
174
+ externalTextareaRef,
175
+ () => localTextareaRef.current as HTMLTextAreaElement,
176
+ );
177
+ const attachmentViews = useMemo(
178
+ () => attachments.map((attachment, index) => getAttachment(attachment, index)),
179
+ [attachments, getAttachment],
180
+ );
181
+ const generating = status === "submitted" || status === "streaming";
182
+ const allReady = attachmentViews.every(
183
+ (attachment) => attachment.status === "ready",
184
+ );
185
+ const canSend =
186
+ (resolvedValue.trim().length > 0 || attachments.length > 0) &&
187
+ !generating &&
188
+ allReady;
189
+
190
+ const changeValue = useCallback(
191
+ (next: string) => {
192
+ if (value === undefined) setInternalValue(next);
193
+ onValueChange?.(next);
194
+ },
195
+ [value, onValueChange],
196
+ );
197
+
198
+ const addFiles = useCallback(
199
+ (incoming: FileList | readonly File[]) => {
200
+ const all = Array.from(incoming);
201
+ const remaining =
202
+ maxFiles === undefined
203
+ ? all.length
204
+ : Math.max(0, maxFiles - attachments.length);
205
+ const selected = multiple ? all.slice(0, remaining) : all.slice(0, 1);
206
+ const valid = selected.filter(
207
+ (file) =>
208
+ matchesAccept(file, accept) &&
209
+ (maxFileSize === undefined || file.size <= maxFileSize),
210
+ );
211
+ const invalid = selected.filter((file) => !valid.includes(file));
212
+ if (all.length > selected.length) {
213
+ onValidationError?.(`最多只能添加 ${maxFiles ?? 1} 个附件`, all);
214
+ }
215
+ if (invalid.length) {
216
+ onValidationError?.("存在不支持或超过大小限制的文件", invalid);
217
+ }
218
+ if (valid.length) void onFilesAdded?.(valid);
219
+ },
220
+ [
221
+ accept,
222
+ attachments.length,
223
+ maxFiles,
224
+ maxFileSize,
225
+ multiple,
226
+ onFilesAdded,
227
+ onValidationError,
228
+ ],
229
+ );
230
+
231
+ const dragging = useComposerDrop({
232
+ scope: dropScope,
233
+ rootRef,
234
+ target: dropTarget,
235
+ onFiles: addFiles,
236
+ });
237
+
238
+ const submit = useCallback(async () => {
239
+ if (!canSend) return;
240
+ await onSubmit({ text: resolvedValue.trim(), attachments });
241
+ if (value === undefined) setInternalValue("");
242
+ }, [canSend, onSubmit, resolvedValue, attachments, value]);
243
+
244
+ const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
245
+ textareaProps?.onKeyDown?.(event);
246
+ if (event.defaultPrevented) return;
247
+ if (
248
+ event.key === "Enter" &&
249
+ !event.shiftKey &&
250
+ !event.nativeEvent.isComposing
251
+ ) {
252
+ event.preventDefault();
253
+ if (canSend) void submit();
254
+ return;
255
+ }
256
+ if (
257
+ event.key === "Backspace" &&
258
+ event.currentTarget.value === "" &&
259
+ attachments.length > 0
260
+ ) {
261
+ const last = attachments[attachments.length - 1];
262
+ if (last) {
263
+ event.preventDefault();
264
+ onAttachmentRemove?.(last);
265
+ }
266
+ }
267
+ };
268
+
269
+ const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
270
+ textareaProps?.onPaste?.(event);
271
+ if (event.defaultPrevented) return;
272
+ const files = Array.from(event.clipboardData.items)
273
+ .filter((item) => item.kind === "file")
274
+ .map((item) => item.getAsFile())
275
+ .filter((file): file is File => file !== null);
276
+ if (files.length) {
277
+ event.preventDefault();
278
+ addFiles(files);
279
+ }
280
+ };
281
+
282
+ return (
283
+ <div
284
+ ref={rootRef}
285
+ className={cn("sb-composer", className)}
286
+ data-drop-scope={dropScope}
287
+ data-dragging={dragging ? "true" : undefined}
288
+ data-status={status}
289
+ {...props}
290
+ >
291
+ <input
292
+ ref={inputRef}
293
+ className="sb-sr-only"
294
+ type="file"
295
+ accept={accept}
296
+ multiple={multiple}
297
+ tabIndex={-1}
298
+ aria-label="上传文件"
299
+ onChange={(event) => {
300
+ if (event.currentTarget.files) addFiles(event.currentTarget.files);
301
+ event.currentTarget.value = "";
302
+ }}
303
+ />
304
+ {attachments.length > 0 && (
305
+ <ComposerHeader>
306
+ <Attachments>
307
+ {attachments.map((attachment, index) => {
308
+ const view = attachmentViews[index];
309
+ return (
310
+ <AttachmentPreview
311
+ key={view.id}
312
+ attachment={view}
313
+ draggable={Boolean(onAttachmentReorder)}
314
+ aria-roledescription={
315
+ onAttachmentReorder ? "可排序附件" : undefined
316
+ }
317
+ onDragStart={(event: DragEvent<HTMLDivElement>) => {
318
+ if (!onAttachmentReorder) return;
319
+ event.dataTransfer.effectAllowed = "move";
320
+ event.dataTransfer.setData(
321
+ ATTACHMENT_DRAG_TYPE,
322
+ String(index),
323
+ );
324
+ }}
325
+ onDragOver={(event: DragEvent<HTMLDivElement>) => {
326
+ if (
327
+ onAttachmentReorder &&
328
+ event.dataTransfer.types.includes(ATTACHMENT_DRAG_TYPE)
329
+ ) {
330
+ event.preventDefault();
331
+ event.dataTransfer.dropEffect = "move";
332
+ }
333
+ }}
334
+ onDrop={(event: DragEvent<HTMLDivElement>) => {
335
+ if (!onAttachmentReorder) return;
336
+ const fromIndex = Number(
337
+ event.dataTransfer.getData(ATTACHMENT_DRAG_TYPE),
338
+ );
339
+ if (
340
+ Number.isInteger(fromIndex) &&
341
+ fromIndex >= 0 &&
342
+ fromIndex < attachments.length &&
343
+ fromIndex !== index
344
+ ) {
345
+ event.preventDefault();
346
+ onAttachmentReorder(fromIndex, index);
347
+ }
348
+ }}
349
+ actions={
350
+ <>
351
+ {view.status === "error" && onAttachmentRetry && (
352
+ <AttachmentRetry onClick={() => void onAttachmentRetry(attachment)} />
353
+ )}
354
+ {onAttachmentReorder && index > 0 && (
355
+ <AttachmentReorder
356
+ direction="left"
357
+ onClick={() => onAttachmentReorder(index, index - 1)}
358
+ />
359
+ )}
360
+ {onAttachmentReorder && index < attachments.length - 1 && (
361
+ <AttachmentReorder
362
+ direction="right"
363
+ onClick={() => onAttachmentReorder(index, index + 1)}
364
+ />
365
+ )}
366
+ {onAttachmentRemove && (
367
+ <AttachmentRemove
368
+ onClick={() => onAttachmentRemove(attachment)}
369
+ />
370
+ )}
371
+ </>
372
+ }
373
+ />
374
+ );
375
+ })}
376
+ </Attachments>
377
+ </ComposerHeader>
378
+ )}
379
+ <ComposerBody>
380
+ <textarea
381
+ {...textareaProps}
382
+ ref={localTextareaRef}
383
+ value={resolvedValue}
384
+ rows={1}
385
+ placeholder={placeholder}
386
+ onChange={(event) => changeValue(event.currentTarget.value)}
387
+ onKeyDown={handleKeyDown}
388
+ onPaste={handlePaste}
389
+ />
390
+ </ComposerBody>
391
+ <ComposerFooter>
392
+ <ComposerTools>
393
+ <button
394
+ type="button"
395
+ className="sb-composer__tool"
396
+ aria-label="添加图片或文件"
397
+ onClick={() => inputRef.current?.click()}
398
+ >
399
+ <PlusIcon size={17} />
400
+ </button>
401
+ {tools}
402
+ </ComposerTools>
403
+ <ComposerSubmit
404
+ status={
405
+ status === "submitted"
406
+ ? "submitted"
407
+ : status === "streaming"
408
+ ? "streaming"
409
+ : "idle"
410
+ }
411
+ disabled={generating ? !onStop : !canSend}
412
+ aria-label={generating ? "停止" : "发送"}
413
+ onClick={() => (generating ? void onStop?.() : void submit())}
414
+ />
415
+ </ComposerFooter>
416
+ {dragging && <DropOverlay />}
417
+ </div>
418
+ );
419
+ }
420
+
421
+ export const Composer = forwardRef(ComposerInner) as <TAttachment>(
422
+ props: ComposerProps<TAttachment> & {
423
+ ref?: React.ForwardedRef<HTMLDivElement>;
424
+ },
425
+ ) => React.ReactElement;
@@ -0,0 +1,80 @@
1
+ import { useEffect, useState, type RefObject } from "react";
2
+ import type { ComposerDropScope } from "./types";
3
+
4
+ function carriesFiles(event: DragEvent): boolean {
5
+ return event.dataTransfer?.types?.includes("Files") === true;
6
+ }
7
+
8
+ export function useComposerDrop({
9
+ scope,
10
+ rootRef,
11
+ target,
12
+ onFiles,
13
+ }: {
14
+ scope: ComposerDropScope;
15
+ rootRef: RefObject<HTMLElement | null>;
16
+ target?: HTMLElement | null;
17
+ onFiles: (files: FileList) => void;
18
+ }) {
19
+ const [dragging, setDragging] = useState(false);
20
+
21
+ useEffect(() => {
22
+ const element =
23
+ scope === "document"
24
+ ? document
25
+ : scope === "panel"
26
+ ? target ??
27
+ rootRef.current?.closest<HTMLElement>("[data-sb-message-panel]") ??
28
+ rootRef.current
29
+ : rootRef.current;
30
+ if (!element) return;
31
+ let depth = 0;
32
+
33
+ const over = (event: Event) => {
34
+ const dragEvent = event as DragEvent;
35
+ if (carriesFiles(dragEvent)) dragEvent.preventDefault();
36
+ };
37
+ const enter = (event: Event) => {
38
+ const dragEvent = event as DragEvent;
39
+ if (!carriesFiles(dragEvent)) return;
40
+ dragEvent.preventDefault();
41
+ depth += 1;
42
+ setDragging(true);
43
+ };
44
+ const leave = (event: Event) => {
45
+ const dragEvent = event as DragEvent;
46
+ if (!carriesFiles(dragEvent)) return;
47
+ depth = Math.max(0, depth - 1);
48
+ if (depth === 0) setDragging(false);
49
+ };
50
+ const drop = (event: Event) => {
51
+ const dragEvent = event as DragEvent;
52
+ if (!carriesFiles(dragEvent)) return;
53
+ dragEvent.preventDefault();
54
+ depth = 0;
55
+ setDragging(false);
56
+ if (dragEvent.dataTransfer?.files.length) onFiles(dragEvent.dataTransfer.files);
57
+ };
58
+ const cancel = () => {
59
+ depth = 0;
60
+ setDragging(false);
61
+ };
62
+
63
+ element.addEventListener("dragover", over);
64
+ element.addEventListener("dragenter", enter);
65
+ element.addEventListener("dragleave", leave);
66
+ element.addEventListener("drop", drop);
67
+ window.addEventListener("dragend", cancel);
68
+ window.addEventListener("blur", cancel);
69
+ return () => {
70
+ element.removeEventListener("dragover", over);
71
+ element.removeEventListener("dragenter", enter);
72
+ element.removeEventListener("dragleave", leave);
73
+ element.removeEventListener("drop", drop);
74
+ window.removeEventListener("dragend", cancel);
75
+ window.removeEventListener("blur", cancel);
76
+ };
77
+ }, [scope, target, rootRef, onFiles]);
78
+
79
+ return dragging;
80
+ }
@@ -0,0 +1,79 @@
1
+ import type {
2
+ HTMLAttributes,
3
+ ReactNode,
4
+ RefObject,
5
+ TextareaHTMLAttributes,
6
+ } from "react";
7
+ import type { MessagePanelStatus } from "../contracts";
8
+
9
+ export type ComposerDropScope = "composer" | "panel" | "document";
10
+ export type AttachmentStatus = "uploading" | "ready" | "error";
11
+
12
+ export interface ComposerAttachmentView {
13
+ id: string;
14
+ name: string;
15
+ mediaType?: string;
16
+ size?: number;
17
+ url?: string;
18
+ status: AttachmentStatus;
19
+ note?: string;
20
+ }
21
+
22
+ export interface ComposerMessage<TAttachment> {
23
+ text: string;
24
+ attachments: readonly TAttachment[];
25
+ }
26
+
27
+ export interface ComposerCommand {
28
+ name: string;
29
+ description: string;
30
+ prompt: string;
31
+ icon?: "plan" | "research" | "file" | "time" | "tool" | "mcp";
32
+ }
33
+
34
+ export interface ComposerToolbarAction {
35
+ id: string;
36
+ label: string;
37
+ icon?: "sparkles" | "plus";
38
+ disabled?: boolean;
39
+ onSelect: () => void;
40
+ }
41
+
42
+ export interface ComposerProps<TAttachment>
43
+ extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "onSubmit"> {
44
+ value?: string;
45
+ defaultValue?: string;
46
+ onValueChange?: (value: string) => void;
47
+ status: MessagePanelStatus;
48
+ attachments?: readonly TAttachment[];
49
+ getAttachment?: (attachment: TAttachment, index: number) => ComposerAttachmentView;
50
+ dropScope?: ComposerDropScope;
51
+ dropTarget?: HTMLElement | null;
52
+ accept?: string;
53
+ multiple?: boolean;
54
+ maxFiles?: number;
55
+ maxFileSize?: number;
56
+ onFilesAdded?: (files: readonly File[]) => void | Promise<void>;
57
+ onAttachmentRemove?: (attachment: TAttachment) => void;
58
+ onAttachmentRetry?: (attachment: TAttachment) => void | Promise<void>;
59
+ onAttachmentReorder?: (fromIndex: number, toIndex: number) => void;
60
+ onValidationError?: (message: string, files: readonly File[]) => void;
61
+ onSubmit: (message: ComposerMessage<TAttachment>) => void | Promise<void>;
62
+ onStop?: () => void | Promise<void>;
63
+ placeholder?: string;
64
+ tools?: ReactNode;
65
+ textareaProps?: Omit<
66
+ TextareaHTMLAttributes<HTMLTextAreaElement>,
67
+ "value" | "defaultValue" | "onChange" | "onSubmit"
68
+ >;
69
+ textareaRef?: RefObject<HTMLTextAreaElement | null>;
70
+ }
71
+
72
+ export interface ChatComposerProps<TAttachment>
73
+ extends ComposerProps<TAttachment> {
74
+ commands?: readonly ComposerCommand[];
75
+ toolbarActions?: readonly ComposerToolbarAction[];
76
+ variant?: "docked" | "standalone";
77
+ commandMenuLabel?: string;
78
+ onCommandPick?: (command: ComposerCommand) => void;
79
+ }