@startsimpli/ui 0.4.25 → 0.4.26
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/package.json +1 -1
- package/src/components/__tests__/chat.test.tsx +188 -1
- package/src/components/__tests__/document-status-badge.test.tsx +48 -0
- package/src/components/badge/DocumentStatusBadge.tsx +76 -0
- package/src/components/badge/StatusBadge.tsx +5 -0
- package/src/components/badge/index.ts +3 -0
- package/src/components/chat/ChatComposer.tsx +307 -27
- package/src/components/chat/ChatMessage.tsx +57 -3
- package/src/components/chat/ChatThread.tsx +29 -2
- package/src/components/chat/attachment-utils.ts +24 -0
- package/src/components/chat/image-downscale.test.ts +28 -0
- package/src/components/chat/image-downscale.ts +88 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +31 -0
- package/src/components/chat/useComposerUploads.test.tsx +125 -0
- package/src/components/chat/useComposerUploads.ts +285 -0
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { SendHorizonal } from 'lucide-react'
|
|
4
|
+
import { Paperclip, RotateCw, SendHorizonal, X } from 'lucide-react'
|
|
5
5
|
import { cn } from '../../lib/utils'
|
|
6
|
+
import type { PendingAttachment } from './types'
|
|
7
|
+
import { formatBytes, isImageName } from './attachment-utils'
|
|
8
|
+
import {
|
|
9
|
+
useComposerUploads,
|
|
10
|
+
type AttachmentUploader,
|
|
11
|
+
type UploadedAttachmentRef,
|
|
12
|
+
} from './useComposerUploads'
|
|
6
13
|
|
|
7
14
|
export interface ChatComposerProps {
|
|
8
15
|
/** Controlled value. Omit for uncontrolled usage. */
|
|
@@ -18,12 +25,63 @@ export interface ChatComposerProps {
|
|
|
18
25
|
submitLabel?: string
|
|
19
26
|
/** Max rows the textarea grows to before scrolling. Default 10. */
|
|
20
27
|
maxRows?: number
|
|
28
|
+
/**
|
|
29
|
+
* Controlled list of pending attachments rendered as chips above the textarea.
|
|
30
|
+
* The APP owns upload state; the composer is presentation-only here.
|
|
31
|
+
*/
|
|
32
|
+
attachments?: PendingAttachment[]
|
|
33
|
+
/**
|
|
34
|
+
* Fired with the selected files from the attach button, a drag-and-drop onto
|
|
35
|
+
* the composer, or a clipboard paste of image/file data. Presence of this
|
|
36
|
+
* handler enables the attach affordances.
|
|
37
|
+
*/
|
|
38
|
+
onAttachFiles?: (files: File[]) => void
|
|
39
|
+
/** Remove a pending attachment chip by id. */
|
|
40
|
+
onRemoveAttachment?: (id: string) => void
|
|
41
|
+
/** Retry a failed attachment chip by id (shows a retry control on error chips). */
|
|
42
|
+
onRetryAttachment?: (id: string) => void
|
|
43
|
+
/**
|
|
44
|
+
* SELF-MANAGED attachment mode. When provided, the composer drives the entire
|
|
45
|
+
* upload lifecycle itself (queued/uploading/ready/error chips, downscale,
|
|
46
|
+
* retry, remove + server-delete, new-deck queue-then-flush-on-`documentId`) by
|
|
47
|
+
* routing the attach button / drag-drop / paste files through this uploader.
|
|
48
|
+
* The app's `@startsimpli/api` `AttachmentsApi` structurally satisfies it.
|
|
49
|
+
*
|
|
50
|
+
* In this mode the controlled props (`attachments`/`onAttachFiles`/
|
|
51
|
+
* `onRemoveAttachment`/`onRetryAttachment`) are ignored — the composer owns
|
|
52
|
+
* the chip state. `onSubmit` still carries text only.
|
|
53
|
+
*/
|
|
54
|
+
uploader?: AttachmentUploader
|
|
55
|
+
/**
|
|
56
|
+
* Document the uploads land on (self-managed mode). May be absent/'new' for a
|
|
57
|
+
* brand-new document: files are held client-side and auto-upload the moment a
|
|
58
|
+
* real id arrives.
|
|
59
|
+
*/
|
|
60
|
+
documentId?: string
|
|
61
|
+
/** Upload purpose tag for self-managed uploads. Default 'reference'. */
|
|
62
|
+
attachmentPurpose?: string
|
|
63
|
+
/** Self-managed mode: fired with the uploaded attachment refs when they change. */
|
|
64
|
+
onAttachmentsChange?: (refs: UploadedAttachmentRef[]) => void
|
|
21
65
|
className?: string
|
|
22
66
|
}
|
|
23
67
|
|
|
24
68
|
/**
|
|
25
69
|
* Chat input with auto-growing textarea. Enter submits, Shift+Enter inserts a
|
|
26
70
|
* newline, and large pastes are accepted as-is. Controlled or uncontrolled.
|
|
71
|
+
*
|
|
72
|
+
* Reference attachments are supported two ways (submit always carries text only):
|
|
73
|
+
*
|
|
74
|
+
* - SELF-MANAGED (recommended, one line): pass an `uploader` (+ `documentId`).
|
|
75
|
+
* The composer drives the full upload lifecycle internally via
|
|
76
|
+
* `useComposerUploads` — downscale, progress, retry, remove + server-delete,
|
|
77
|
+
* and new-deck queue-then-flush — routing attach button / drag-drop / paste
|
|
78
|
+
* files through the uploader. The app wires nothing else.
|
|
79
|
+
*
|
|
80
|
+
* - CONTROLLED (legacy): the app owns upload state and feeds `attachments`
|
|
81
|
+
* down; the composer renders chips and surfaces new files via `onAttachFiles`
|
|
82
|
+
* (`onRemoveAttachment`/`onRetryAttachment` for chip actions).
|
|
83
|
+
*
|
|
84
|
+
* Providing `uploader` switches to self-managed and ignores the controlled props.
|
|
27
85
|
*/
|
|
28
86
|
export function ChatComposer({
|
|
29
87
|
value,
|
|
@@ -34,12 +92,49 @@ export function ChatComposer({
|
|
|
34
92
|
busy = false,
|
|
35
93
|
submitLabel = 'Send',
|
|
36
94
|
maxRows = 10,
|
|
95
|
+
attachments,
|
|
96
|
+
onAttachFiles,
|
|
97
|
+
onRemoveAttachment,
|
|
98
|
+
onRetryAttachment,
|
|
99
|
+
uploader,
|
|
100
|
+
documentId,
|
|
101
|
+
attachmentPurpose,
|
|
102
|
+
onAttachmentsChange,
|
|
37
103
|
className,
|
|
38
104
|
}: ChatComposerProps) {
|
|
39
105
|
const isControlled = value !== undefined
|
|
40
106
|
const [internal, setInternal] = React.useState('')
|
|
41
107
|
const text = isControlled ? value! : internal
|
|
42
108
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
|
|
109
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
|
110
|
+
const [dragOver, setDragOver] = React.useState(false)
|
|
111
|
+
|
|
112
|
+
// SELF-MANAGED mode: when an `uploader` is supplied, the composer owns the
|
|
113
|
+
// upload state and feeds its own chips/handlers in place of the controlled
|
|
114
|
+
// props. The hook runs unconditionally (Rules of Hooks); a no-op uploader
|
|
115
|
+
// keeps it inert when in controlled mode.
|
|
116
|
+
const selfManaged = !!uploader
|
|
117
|
+
const noopUploader = React.useRef<AttachmentUploader>({
|
|
118
|
+
async uploadAttachment() {
|
|
119
|
+
return { id: '', fileUrl: null }
|
|
120
|
+
},
|
|
121
|
+
async deleteAttachment() {
|
|
122
|
+
/* noop */
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
const uploads = useComposerUploads(uploader ?? noopUploader.current, documentId, {
|
|
126
|
+
purpose: attachmentPurpose,
|
|
127
|
+
onAttachmentsChange,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Resolve effective chips/handlers from whichever mode is active.
|
|
131
|
+
const effectiveAttachments = selfManaged ? uploads.attachments : attachments
|
|
132
|
+
const effectiveAttachFiles = selfManaged ? uploads.attachFiles : onAttachFiles
|
|
133
|
+
const effectiveRemove = selfManaged ? uploads.removeAttachment : onRemoveAttachment
|
|
134
|
+
const effectiveRetry = selfManaged ? uploads.retryAttachment : onRetryAttachment
|
|
135
|
+
|
|
136
|
+
const attachEnabled = !!effectiveAttachFiles && !disabled
|
|
137
|
+
const chips = effectiveAttachments ?? []
|
|
43
138
|
|
|
44
139
|
const setText = (next: string) => {
|
|
45
140
|
if (!isControlled) setInternal(next)
|
|
@@ -61,6 +156,11 @@ export function ChatComposer({
|
|
|
61
156
|
if (!canSubmit) return
|
|
62
157
|
onSubmit(text.trim())
|
|
63
158
|
if (!isControlled) setInternal('')
|
|
159
|
+
// Self-managed chips have been (or are being) uploaded to the document; the
|
|
160
|
+
// consumer reads them off the server on its turn, so clear them from the
|
|
161
|
+
// composer once the message goes out. (Controlled mode leaves chip lifecycle
|
|
162
|
+
// entirely to the app.)
|
|
163
|
+
if (selfManaged) uploads.clear()
|
|
64
164
|
}
|
|
65
165
|
|
|
66
166
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
@@ -80,34 +180,214 @@ export function ChatComposer({
|
|
|
80
180
|
submit()
|
|
81
181
|
}
|
|
82
182
|
|
|
183
|
+
const emitFiles = (list: FileList | null | undefined) => {
|
|
184
|
+
if (!attachEnabled || !list || list.length === 0) return
|
|
185
|
+
effectiveAttachFiles!(Array.from(list))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const onPickClick = () => fileInputRef.current?.click()
|
|
189
|
+
|
|
190
|
+
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
191
|
+
emitFiles(e.target.files)
|
|
192
|
+
// reset so picking the same file again re-fires change
|
|
193
|
+
e.target.value = ''
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const onDrop = (e: React.DragEvent) => {
|
|
197
|
+
if (!attachEnabled) return
|
|
198
|
+
const files = e.dataTransfer?.files
|
|
199
|
+
if (files && files.length > 0) {
|
|
200
|
+
e.preventDefault()
|
|
201
|
+
setDragOver(false)
|
|
202
|
+
emitFiles(files)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const onDragOver = (e: React.DragEvent) => {
|
|
207
|
+
if (!attachEnabled) return
|
|
208
|
+
// Only treat as a file drag (not text selection drags).
|
|
209
|
+
if (Array.from(e.dataTransfer?.types ?? []).includes('Files')) {
|
|
210
|
+
e.preventDefault()
|
|
211
|
+
setDragOver(true)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const onDragLeave = () => setDragOver(false)
|
|
216
|
+
|
|
217
|
+
const onPaste = (e: React.ClipboardEvent) => {
|
|
218
|
+
if (!attachEnabled) return
|
|
219
|
+
const files = e.clipboardData?.files
|
|
220
|
+
if (files && files.length > 0) {
|
|
221
|
+
// Pasted image/file data — capture it as an attachment, not text.
|
|
222
|
+
e.preventDefault()
|
|
223
|
+
emitFiles(files)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
83
227
|
return (
|
|
84
|
-
<div
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
228
|
+
<div
|
|
229
|
+
className={cn(
|
|
230
|
+
'flex flex-col gap-2 border-t border-border bg-background p-3',
|
|
231
|
+
dragOver && 'ring-2 ring-inset ring-ring',
|
|
232
|
+
className
|
|
233
|
+
)}
|
|
234
|
+
onDrop={onDrop}
|
|
235
|
+
onDragOver={onDragOver}
|
|
236
|
+
onDragLeave={onDragLeave}
|
|
237
|
+
>
|
|
238
|
+
{chips.length > 0 && (
|
|
239
|
+
<ul className="flex flex-wrap gap-2" data-testid="chat-attachment-chips">
|
|
240
|
+
{chips.map((a) => (
|
|
241
|
+
<AttachmentChip
|
|
242
|
+
key={a.id}
|
|
243
|
+
attachment={a}
|
|
244
|
+
onRemove={effectiveRemove}
|
|
245
|
+
onRetry={effectiveRetry}
|
|
246
|
+
/>
|
|
247
|
+
))}
|
|
248
|
+
</ul>
|
|
249
|
+
)}
|
|
250
|
+
<div className="flex items-end gap-2">
|
|
251
|
+
{attachEnabled && (
|
|
252
|
+
<>
|
|
253
|
+
<input
|
|
254
|
+
ref={fileInputRef}
|
|
255
|
+
type="file"
|
|
256
|
+
multiple
|
|
257
|
+
className="sr-only"
|
|
258
|
+
data-testid="chat-attach-input"
|
|
259
|
+
onChange={onInputChange}
|
|
260
|
+
/>
|
|
261
|
+
<button
|
|
262
|
+
type="button"
|
|
263
|
+
onClick={onPickClick}
|
|
264
|
+
aria-label="Attach files"
|
|
265
|
+
className={cn(
|
|
266
|
+
'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input bg-background text-muted-foreground shadow-sm transition-colors',
|
|
267
|
+
'hover:bg-accent hover:text-foreground'
|
|
268
|
+
)}
|
|
269
|
+
>
|
|
270
|
+
<Paperclip className="h-4 w-4" aria-hidden="true" />
|
|
271
|
+
</button>
|
|
272
|
+
</>
|
|
97
273
|
)}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
274
|
+
<textarea
|
|
275
|
+
ref={textareaRef}
|
|
276
|
+
value={text}
|
|
277
|
+
onChange={(e) => setText(e.target.value)}
|
|
278
|
+
onKeyDown={onKeyDown}
|
|
279
|
+
onPaste={attachEnabled ? onPaste : undefined}
|
|
280
|
+
placeholder={placeholder}
|
|
281
|
+
disabled={disabled}
|
|
282
|
+
rows={1}
|
|
283
|
+
className={cn(
|
|
284
|
+
'flex-1 resize-none rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm',
|
|
285
|
+
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
286
|
+
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
287
|
+
)}
|
|
288
|
+
/>
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
onClick={submit}
|
|
292
|
+
disabled={!canSubmit}
|
|
293
|
+
aria-label={submitLabel}
|
|
294
|
+
className={cn(
|
|
295
|
+
'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-opacity',
|
|
296
|
+
'hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40'
|
|
297
|
+
)}
|
|
298
|
+
>
|
|
299
|
+
<SendHorizonal className="h-4 w-4" aria-hidden="true" />
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
111
302
|
</div>
|
|
112
303
|
)
|
|
113
304
|
}
|
|
305
|
+
|
|
306
|
+
function AttachmentChip({
|
|
307
|
+
attachment,
|
|
308
|
+
onRemove,
|
|
309
|
+
onRetry,
|
|
310
|
+
}: {
|
|
311
|
+
attachment: PendingAttachment
|
|
312
|
+
onRemove?: (id: string) => void
|
|
313
|
+
onRetry?: (id: string) => void
|
|
314
|
+
}) {
|
|
315
|
+
const { id, name, status, progress, thumbnailUrl, sizeBytes, error } = attachment
|
|
316
|
+
const isImage = !!thumbnailUrl && isImageName(name)
|
|
317
|
+
const pct = Math.round((progress ?? 0) * 100)
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<li
|
|
321
|
+
data-testid={`chat-attachment-${id}`}
|
|
322
|
+
data-status={status}
|
|
323
|
+
className={cn(
|
|
324
|
+
'group relative flex items-center gap-2 rounded-md border border-border bg-muted/40 py-1 pl-1 pr-2 text-xs',
|
|
325
|
+
status === 'error' && 'border-red-300 bg-red-50 text-red-700'
|
|
326
|
+
)}
|
|
327
|
+
>
|
|
328
|
+
{isImage ? (
|
|
329
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
330
|
+
<img
|
|
331
|
+
src={thumbnailUrl}
|
|
332
|
+
alt={name}
|
|
333
|
+
className="h-8 w-8 shrink-0 rounded object-cover"
|
|
334
|
+
/>
|
|
335
|
+
) : (
|
|
336
|
+
<span
|
|
337
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground"
|
|
338
|
+
aria-hidden="true"
|
|
339
|
+
>
|
|
340
|
+
<Paperclip className="h-4 w-4" />
|
|
341
|
+
</span>
|
|
342
|
+
)}
|
|
343
|
+
<span className="flex min-w-0 flex-col">
|
|
344
|
+
<span className="max-w-[10rem] truncate font-medium" title={name}>
|
|
345
|
+
{name}
|
|
346
|
+
</span>
|
|
347
|
+
<span className="text-[10px] text-muted-foreground">
|
|
348
|
+
{status === 'uploading'
|
|
349
|
+
? `Uploading… ${pct}%`
|
|
350
|
+
: status === 'error'
|
|
351
|
+
? (error || 'Upload failed')
|
|
352
|
+
: sizeBytes != null
|
|
353
|
+
? formatBytes(sizeBytes)
|
|
354
|
+
: null}
|
|
355
|
+
</span>
|
|
356
|
+
</span>
|
|
357
|
+
{status === 'error' && onRetry && (
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
onClick={() => onRetry(id)}
|
|
361
|
+
aria-label={`Retry ${name}`}
|
|
362
|
+
className="ml-1 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-red-600 hover:bg-red-100"
|
|
363
|
+
>
|
|
364
|
+
<RotateCw className="h-3 w-3" aria-hidden="true" />
|
|
365
|
+
</button>
|
|
366
|
+
)}
|
|
367
|
+
{status === 'uploading' && (
|
|
368
|
+
<span
|
|
369
|
+
role="progressbar"
|
|
370
|
+
aria-valuemin={0}
|
|
371
|
+
aria-valuemax={100}
|
|
372
|
+
aria-valuenow={pct}
|
|
373
|
+
className="absolute inset-x-1 bottom-0 h-1 overflow-hidden rounded-full bg-muted"
|
|
374
|
+
>
|
|
375
|
+
<span
|
|
376
|
+
className="block h-full bg-primary transition-[width]"
|
|
377
|
+
style={{ width: `${pct}%` }}
|
|
378
|
+
/>
|
|
379
|
+
</span>
|
|
380
|
+
)}
|
|
381
|
+
{onRemove && (
|
|
382
|
+
<button
|
|
383
|
+
type="button"
|
|
384
|
+
onClick={() => onRemove(id)}
|
|
385
|
+
aria-label={`Remove ${name}`}
|
|
386
|
+
className="ml-1 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground"
|
|
387
|
+
>
|
|
388
|
+
<X className="h-3 w-3" aria-hidden="true" />
|
|
389
|
+
</button>
|
|
390
|
+
)}
|
|
391
|
+
</li>
|
|
392
|
+
)
|
|
393
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import { Wrench } from 'lucide-react'
|
|
4
|
+
import { Paperclip, Wrench } from 'lucide-react'
|
|
5
5
|
import { cn } from '../../lib/utils'
|
|
6
|
-
import type { ChatMessageData } from './types'
|
|
6
|
+
import type { ChatMessageAttachment, ChatMessageData } from './types'
|
|
7
|
+
import { isImageName } from './attachment-utils'
|
|
7
8
|
|
|
8
9
|
export interface ChatMessageProps {
|
|
9
10
|
message: ChatMessageData
|
|
@@ -27,8 +28,58 @@ function StreamingDots() {
|
|
|
27
28
|
* assistant left); `tool` / `status` kinds render as compact centered system
|
|
28
29
|
* rows. Fully generic — no presentation-specific knowledge.
|
|
29
30
|
*/
|
|
31
|
+
/** Attachment chips/thumbnails rendered on a message bubble. */
|
|
32
|
+
function MessageAttachments({
|
|
33
|
+
attachments,
|
|
34
|
+
isUser,
|
|
35
|
+
}: {
|
|
36
|
+
attachments: ChatMessageAttachment[]
|
|
37
|
+
isUser: boolean
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<ul className="mt-2 flex flex-wrap gap-2" data-testid="chat-message-attachments">
|
|
41
|
+
{attachments.map((a) => {
|
|
42
|
+
const isImage = !!a.fileUrl && isImageName(a.contentType || a.filename)
|
|
43
|
+
const chip = (
|
|
44
|
+
<span
|
|
45
|
+
className={cn(
|
|
46
|
+
'flex items-center gap-1.5 rounded-md border px-1.5 py-1 text-xs',
|
|
47
|
+
isUser
|
|
48
|
+
? 'border-primary-foreground/30 bg-primary-foreground/10'
|
|
49
|
+
: 'border-border bg-background/60'
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<Paperclip className="h-3 w-3 shrink-0" aria-hidden="true" />
|
|
53
|
+
<span className="max-w-[10rem] truncate" title={a.filename}>
|
|
54
|
+
{a.filename}
|
|
55
|
+
</span>
|
|
56
|
+
</span>
|
|
57
|
+
)
|
|
58
|
+
return (
|
|
59
|
+
<li key={a.id} data-testid={`chat-message-attachment-${a.id}`}>
|
|
60
|
+
{isImage ? (
|
|
61
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
62
|
+
<img
|
|
63
|
+
src={a.fileUrl!}
|
|
64
|
+
alt={a.filename}
|
|
65
|
+
className="h-16 w-16 rounded-md border border-border object-cover"
|
|
66
|
+
/>
|
|
67
|
+
) : a.fileUrl ? (
|
|
68
|
+
<a href={a.fileUrl} target="_blank" rel="noreferrer" className="hover:opacity-80">
|
|
69
|
+
{chip}
|
|
70
|
+
</a>
|
|
71
|
+
) : (
|
|
72
|
+
chip
|
|
73
|
+
)}
|
|
74
|
+
</li>
|
|
75
|
+
)
|
|
76
|
+
})}
|
|
77
|
+
</ul>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
30
81
|
export function ChatMessage({ message, renderContent, className }: ChatMessageProps) {
|
|
31
|
-
const { role, content, status, kind = 'text', toolName } = message
|
|
82
|
+
const { role, content, status, kind = 'text', toolName, attachments } = message
|
|
32
83
|
const isStreaming = status === 'streaming'
|
|
33
84
|
const isError = status === 'error'
|
|
34
85
|
|
|
@@ -75,6 +126,9 @@ export function ChatMessage({ message, renderContent, className }: ChatMessagePr
|
|
|
75
126
|
>
|
|
76
127
|
{body}
|
|
77
128
|
{isStreaming && <StreamingDots />}
|
|
129
|
+
{attachments && attachments.length > 0 && (
|
|
130
|
+
<MessageAttachments attachments={attachments} isUser={isUser} />
|
|
131
|
+
)}
|
|
78
132
|
</div>
|
|
79
133
|
</div>
|
|
80
134
|
)
|
|
@@ -14,9 +14,32 @@ export interface ChatThreadProps {
|
|
|
14
14
|
/** Auto-scroll to the newest message on change. Default true. */
|
|
15
15
|
autoScroll?: boolean
|
|
16
16
|
renderContent?: (content: string, message: ChatMessageData) => React.ReactNode
|
|
17
|
+
/** Show an animated "working" indicator at the end (the agent is processing). */
|
|
18
|
+
busy?: boolean
|
|
19
|
+
/** Label shown next to the working indicator. Default "Working…". */
|
|
20
|
+
busyLabel?: string
|
|
17
21
|
className?: string
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
/** Animated three-dot "agent is working" row, aligned like an assistant message. */
|
|
25
|
+
function WorkingIndicator({ label = 'Working…' }: { label?: string }) {
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
className="flex items-center gap-2 px-1 text-sm text-muted-foreground"
|
|
29
|
+
role="status"
|
|
30
|
+
aria-live="polite"
|
|
31
|
+
data-testid="chat-working-indicator"
|
|
32
|
+
>
|
|
33
|
+
<span className="flex gap-1" aria-hidden="true">
|
|
34
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
|
|
35
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
|
|
36
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current" />
|
|
37
|
+
</span>
|
|
38
|
+
{label}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
/**
|
|
21
44
|
* Scrollable, auto-scrolling list of chat messages. App-agnostic.
|
|
22
45
|
*/
|
|
@@ -26,15 +49,18 @@ export function ChatThread({
|
|
|
26
49
|
renderMessage,
|
|
27
50
|
autoScroll = true,
|
|
28
51
|
renderContent,
|
|
52
|
+
busy = false,
|
|
53
|
+
busyLabel,
|
|
29
54
|
className,
|
|
30
55
|
}: ChatThreadProps) {
|
|
31
56
|
const anchorRef = React.useRef<HTMLDivElement>(null)
|
|
32
57
|
|
|
33
58
|
React.useEffect(() => {
|
|
34
59
|
if (autoScroll) anchorRef.current?.scrollIntoView({ block: 'end' })
|
|
35
|
-
}, [messages, autoScroll])
|
|
60
|
+
}, [messages, busy, autoScroll])
|
|
36
61
|
|
|
37
|
-
|
|
62
|
+
// Empty thread + not working → the caller's empty state.
|
|
63
|
+
if (messages.length === 0 && !busy && emptyState) {
|
|
38
64
|
return (
|
|
39
65
|
<div className={cn('flex h-full items-center justify-center p-6 text-center', className)}>
|
|
40
66
|
{emptyState}
|
|
@@ -51,6 +77,7 @@ export function ChatThread({
|
|
|
51
77
|
<ChatMessage key={m.id} message={m} renderContent={renderContent} />
|
|
52
78
|
)
|
|
53
79
|
)}
|
|
80
|
+
{busy && <WorkingIndicator label={busyLabel} />}
|
|
54
81
|
<div ref={anchorRef} data-testid="chat-thread-anchor" />
|
|
55
82
|
</div>
|
|
56
83
|
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Small presentation helpers shared by the chat attachment chips/thumbnails. */
|
|
2
|
+
|
|
3
|
+
/** Human-readable byte size, e.g. 2048 -> "2 KB", 1536000 -> "1.5 MB". */
|
|
4
|
+
export function formatBytes(bytes: number): string {
|
|
5
|
+
if (!Number.isFinite(bytes) || bytes < 0) return ''
|
|
6
|
+
if (bytes < 1024) return `${bytes} B`
|
|
7
|
+
const units = ['KB', 'MB', 'GB', 'TB']
|
|
8
|
+
let value = bytes / 1024
|
|
9
|
+
let i = 0
|
|
10
|
+
while (value >= 1024 && i < units.length - 1) {
|
|
11
|
+
value /= 1024
|
|
12
|
+
i++
|
|
13
|
+
}
|
|
14
|
+
const rounded = value >= 10 || Number.isInteger(value) ? Math.round(value) : Math.round(value * 10) / 10
|
|
15
|
+
return `${rounded} ${units[i]}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|bmp|svg|heic|heif)$/i
|
|
19
|
+
|
|
20
|
+
/** Whether a filename or content-type looks like a previewable image. */
|
|
21
|
+
export function isImageName(nameOrType: string | undefined | null): boolean {
|
|
22
|
+
if (!nameOrType) return false
|
|
23
|
+
return nameOrType.startsWith('image/') || IMAGE_EXT.test(nameOrType)
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { downscaleImage } from './image-downscale'
|
|
2
|
+
|
|
3
|
+
function file(name: string, type: string, bytes = 100) {
|
|
4
|
+
return new File([new Uint8Array(bytes)], name, { type })
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
describe('downscaleImage (best-effort, jsdom has no canvas/createImageBitmap)', () => {
|
|
8
|
+
it('returns non-image files untouched', async () => {
|
|
9
|
+
const f = file('deck.pdf', 'application/pdf')
|
|
10
|
+
expect(await downscaleImage(f)).toBe(f)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns GIFs untouched (may be animated)', async () => {
|
|
14
|
+
const f = file('anim.gif', 'image/gif')
|
|
15
|
+
expect(await downscaleImage(f)).toBe(f)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns SVGs untouched (vector)', async () => {
|
|
19
|
+
const f = file('logo.svg', 'image/svg+xml')
|
|
20
|
+
expect(await downscaleImage(f)).toBe(f)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('falls back to the original image when the environment cannot rasterize', async () => {
|
|
24
|
+
// In jsdom createImageBitmap is undefined → best-effort returns original.
|
|
25
|
+
const f = file('photo.png', 'image/png', 5_000_000)
|
|
26
|
+
expect(await downscaleImage(f)).toBe(f)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort client-side image downscale for uploads.
|
|
3
|
+
*
|
|
4
|
+
* Reference images (e.g. an old deck slide a user drops in to clone the look)
|
|
5
|
+
* are often multi-MB originals, but the backend reference-extraction normalizes
|
|
6
|
+
* everything to ~1536px anyway — so uploading full-res is wasteful, slow, and
|
|
7
|
+
* (on a constrained proxy) can hang. We downscale in the browser first.
|
|
8
|
+
*
|
|
9
|
+
* ALWAYS best-effort: any failure (no DOM, unsupported codec, decode error)
|
|
10
|
+
* returns the ORIGINAL file untouched, so this can never block an upload. Only
|
|
11
|
+
* raster images above the threshold are touched; non-images, GIFs (possibly
|
|
12
|
+
* animated), SVGs, and already-small images pass through verbatim.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface DownscaleOptions {
|
|
16
|
+
/** Max width/height of the output (longest side). Default 1536 (model limit). */
|
|
17
|
+
maxDim?: number
|
|
18
|
+
/** JPEG quality 0..1 for the re-encoded output. Default 0.82. */
|
|
19
|
+
quality?: number
|
|
20
|
+
/** Skip downscale for files at/under this size when also within maxDim. Default 1.5MB. */
|
|
21
|
+
sizeThresholdBytes?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULTS = { maxDim: 1536, quality: 0.82, sizeThresholdBytes: 1_500_000 }
|
|
25
|
+
|
|
26
|
+
/** True for raster image types we can safely re-encode (excludes gif/svg). */
|
|
27
|
+
function isDownscalableImage(file: File): boolean {
|
|
28
|
+
return (
|
|
29
|
+
file.type.startsWith('image/') &&
|
|
30
|
+
file.type !== 'image/gif' &&
|
|
31
|
+
file.type !== 'image/svg+xml'
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function downscaleImage(file: File, options: DownscaleOptions = {}): Promise<File> {
|
|
36
|
+
const { maxDim, quality, sizeThresholdBytes } = { ...DEFAULTS, ...options }
|
|
37
|
+
|
|
38
|
+
// Bail to the original whenever we can't (or needn't) do better.
|
|
39
|
+
if (
|
|
40
|
+
typeof document === 'undefined' ||
|
|
41
|
+
typeof createImageBitmap === 'undefined' ||
|
|
42
|
+
!isDownscalableImage(file)
|
|
43
|
+
) {
|
|
44
|
+
return file
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const bitmap = await createImageBitmap(file)
|
|
49
|
+
const longest = Math.max(bitmap.width, bitmap.height)
|
|
50
|
+
const scale = Math.min(1, maxDim / longest)
|
|
51
|
+
|
|
52
|
+
// Already within bounds and small enough — keep the original (preserves
|
|
53
|
+
// format/quality; e.g. a crisp PNG screenshot under the threshold).
|
|
54
|
+
if (scale >= 1 && file.size <= sizeThresholdBytes) {
|
|
55
|
+
bitmap.close?.()
|
|
56
|
+
return file
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const w = Math.max(1, Math.round(bitmap.width * scale))
|
|
60
|
+
const h = Math.max(1, Math.round(bitmap.height * scale))
|
|
61
|
+
const canvas = document.createElement('canvas')
|
|
62
|
+
canvas.width = w
|
|
63
|
+
canvas.height = h
|
|
64
|
+
const ctx = canvas.getContext('2d')
|
|
65
|
+
if (!ctx) {
|
|
66
|
+
bitmap.close?.()
|
|
67
|
+
return file
|
|
68
|
+
}
|
|
69
|
+
// Flatten onto white so transparency doesn't become black in JPEG.
|
|
70
|
+
ctx.fillStyle = '#ffffff'
|
|
71
|
+
ctx.fillRect(0, 0, w, h)
|
|
72
|
+
ctx.drawImage(bitmap, 0, 0, w, h)
|
|
73
|
+
bitmap.close?.()
|
|
74
|
+
|
|
75
|
+
const blob = await new Promise<Blob | null>((resolve) =>
|
|
76
|
+
canvas.toBlob(resolve, 'image/jpeg', quality)
|
|
77
|
+
)
|
|
78
|
+
if (!blob || blob.size >= file.size) return file // no win → keep original
|
|
79
|
+
|
|
80
|
+
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'
|
|
81
|
+
return new File([blob], `${baseName}.jpg`, {
|
|
82
|
+
type: 'image/jpeg',
|
|
83
|
+
lastModified: file.lastModified,
|
|
84
|
+
})
|
|
85
|
+
} catch {
|
|
86
|
+
return file
|
|
87
|
+
}
|
|
88
|
+
}
|