@startsimpli/ui 0.4.24 → 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.
@@ -4,9 +4,21 @@ export { ChatThread } from './ChatThread'
4
4
  export type { ChatThreadProps } from './ChatThread'
5
5
  export { ChatComposer } from './ChatComposer'
6
6
  export type { ChatComposerProps } from './ChatComposer'
7
+ export { useComposerUploads } from './useComposerUploads'
8
+ export type {
9
+ AttachmentUploader,
10
+ UploadedAttachmentRef,
11
+ UseComposerUploadsOptions,
12
+ UseComposerUploadsResult,
13
+ } from './useComposerUploads'
14
+ export { downscaleImage } from './image-downscale'
15
+ export type { DownscaleOptions } from './image-downscale'
7
16
  export type {
8
17
  ChatMessageData,
18
+ ChatMessageAttachment,
19
+ PendingAttachment,
9
20
  ChatRole,
10
21
  ChatMessageStatus,
11
22
  ChatMessageKind,
12
23
  } from './types'
24
+ export { formatBytes, isImageName } from './attachment-utils'
@@ -4,6 +4,18 @@ export type ChatMessageStatus = 'streaming' | 'complete' | 'error'
4
4
 
5
5
  export type ChatMessageKind = 'text' | 'tool' | 'status'
6
6
 
7
+ /**
8
+ * A reference attachment already persisted on a message (server shape). Rendered
9
+ * as a chip/thumbnail on the message that carries it.
10
+ */
11
+ export interface ChatMessageAttachment {
12
+ id: string
13
+ /** Asset url (image thumbnails / download link); may be null while pending. */
14
+ fileUrl: string | null
15
+ filename: string
16
+ contentType: string
17
+ }
18
+
7
19
  export interface ChatMessageData {
8
20
  id: string
9
21
  role: ChatRole
@@ -17,4 +29,23 @@ export interface ChatMessageData {
17
29
  kind?: ChatMessageKind
18
30
  /** Name of the tool when kind === 'tool'. */
19
31
  toolName?: string
32
+ /** Reference attachments carried by this message (rendered as chips). */
33
+ attachments?: ChatMessageAttachment[]
34
+ }
35
+
36
+ /**
37
+ * A composer-side pending attachment. The APP owns upload state and feeds these
38
+ * down as a controlled prop; the composer renders them as chips with progress.
39
+ */
40
+ export interface PendingAttachment {
41
+ id: string
42
+ name: string
43
+ status: 'uploading' | 'ready' | 'error'
44
+ /** Upload progress 0..1 while `status === 'uploading'`. */
45
+ progress?: number
46
+ /** Object/asset url for an image preview thumbnail. */
47
+ thumbnailUrl?: string
48
+ sizeBytes?: number
49
+ /** Human-readable failure reason shown on `status === 'error'` chips. */
50
+ error?: string
20
51
  }
@@ -0,0 +1,125 @@
1
+ import { renderHook, act, waitFor } from '@testing-library/react'
2
+ import { useComposerUploads, type AttachmentUploader } from './useComposerUploads'
3
+
4
+ function fakeUploader(overrides: Partial<AttachmentUploader> = {}): AttachmentUploader {
5
+ return {
6
+ uploadAttachment: jest.fn(async (_id: string, file: File) => ({
7
+ id: `srv-${file.name}`,
8
+ fileUrl: `https://cdn/${file.name}`,
9
+ })),
10
+ deleteAttachment: jest.fn(async () => undefined),
11
+ ...overrides,
12
+ }
13
+ }
14
+
15
+ function file(name: string, type = 'image/png') {
16
+ return new File(['x'], name, { type })
17
+ }
18
+
19
+ beforeEach(() => {
20
+ if (!('createObjectURL' in URL)) {
21
+ // @ts-expect-error test shim
22
+ URL.createObjectURL = jest.fn(() => 'blob:preview')
23
+ }
24
+ if (!('revokeObjectURL' in URL)) {
25
+ // @ts-expect-error test shim
26
+ URL.revokeObjectURL = jest.fn()
27
+ }
28
+ })
29
+
30
+ describe('useComposerUploads', () => {
31
+ it('uploads immediately when a real documentId exists and reports ready', async () => {
32
+ const uploader = fakeUploader()
33
+ const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
34
+
35
+ act(() => result.current.attachFiles([file('logo.png')]))
36
+ expect(result.current.attachments).toHaveLength(1)
37
+
38
+ await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
39
+ expect(uploader.uploadAttachment).toHaveBeenCalledTimes(1)
40
+ expect(uploader.uploadAttachment).toHaveBeenCalledWith('doc-1', expect.any(File), expect.any(Object))
41
+ })
42
+
43
+ it('non-image files pass through downscale untouched (still upload)', async () => {
44
+ const uploader = fakeUploader()
45
+ const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
46
+ const pdf = file('notes.pdf', 'application/pdf')
47
+ act(() => result.current.attachFiles([pdf]))
48
+ await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
49
+ // The exact File instance is forwarded (downscale is a no-op on non-images).
50
+ expect((uploader.uploadAttachment as jest.Mock).mock.calls[0][1]).toBe(pdf)
51
+ })
52
+
53
+ it('holds files client-side when there is no documentId yet, then uploads once it appears (new-deck case)', async () => {
54
+ const uploader = fakeUploader()
55
+ let docId: string | undefined
56
+ const { result, rerender } = renderHook(({ id }: { id?: string }) => useComposerUploads(uploader, id), {
57
+ initialProps: { id: docId },
58
+ })
59
+
60
+ act(() => result.current.attachFiles([file('brand.png')]))
61
+ expect(result.current.attachments).toHaveLength(1)
62
+ expect(uploader.uploadAttachment).not.toHaveBeenCalled()
63
+
64
+ docId = 'doc-new'
65
+ rerender({ id: docId })
66
+
67
+ await waitFor(() => expect(uploader.uploadAttachment).toHaveBeenCalledTimes(1))
68
+ expect(uploader.uploadAttachment).toHaveBeenCalledWith('doc-new', expect.any(File), expect.any(Object))
69
+ await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
70
+ })
71
+
72
+ it('marks a chip as error (with the failure reason) when the upload fails', async () => {
73
+ const uploader = fakeUploader({
74
+ uploadAttachment: jest.fn().mockRejectedValue(new Error('boom')),
75
+ })
76
+ const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
77
+ act(() => result.current.attachFiles([file('bad.png')]))
78
+ await waitFor(() => expect(result.current.attachments[0].status).toBe('error'))
79
+ expect(result.current.attachments[0].error).toBe('boom')
80
+ })
81
+
82
+ it('retryAttachment re-uploads a failed chip and reaches ready', async () => {
83
+ const upload = jest
84
+ .fn()
85
+ .mockRejectedValueOnce(new Error('flaky network'))
86
+ .mockResolvedValueOnce({ id: 'srv-retry', fileUrl: 'https://cdn/r.png' })
87
+ const uploader = fakeUploader({ uploadAttachment: upload })
88
+ const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
89
+ act(() => result.current.attachFiles([file('r.png')]))
90
+ await waitFor(() => expect(result.current.attachments[0].status).toBe('error'))
91
+ const chipId = result.current.attachments[0].id
92
+
93
+ act(() => result.current.retryAttachment(chipId))
94
+ await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
95
+ expect(result.current.attachments[0].error).toBeUndefined()
96
+ expect(upload).toHaveBeenCalledTimes(2)
97
+ })
98
+
99
+ it('removes a pending chip and deletes an uploaded attachment server-side', async () => {
100
+ const uploader = fakeUploader()
101
+ const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
102
+ act(() => result.current.attachFiles([file('a.png')]))
103
+ await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
104
+ const chipId = result.current.attachments[0].id
105
+
106
+ act(() => result.current.removeAttachment(chipId))
107
+ expect(result.current.attachments).toHaveLength(0)
108
+ expect(uploader.deleteAttachment).toHaveBeenCalledWith('doc-1', 'srv-a.png')
109
+ })
110
+
111
+ it('surfaces uploaded refs via onAttachmentsChange when a chip reaches ready', async () => {
112
+ const uploader = fakeUploader()
113
+ const onAttachmentsChange = jest.fn()
114
+ const { result } = renderHook(() =>
115
+ useComposerUploads(uploader, 'doc-1', { onAttachmentsChange })
116
+ )
117
+ act(() => result.current.attachFiles([file('hero.png')]))
118
+ await waitFor(() => expect(result.current.attachments[0].status).toBe('ready'))
119
+ await waitFor(() =>
120
+ expect(onAttachmentsChange).toHaveBeenLastCalledWith([
121
+ { id: 'srv-hero.png', fileUrl: 'https://cdn/hero.png', name: 'hero.png' },
122
+ ])
123
+ )
124
+ })
125
+ })
@@ -0,0 +1,285 @@
1
+ import * as React from 'react'
2
+ import type { PendingAttachment } from './types'
3
+ import { downscaleImage } from './image-downscale'
4
+
5
+ /**
6
+ * Structural interface for the upload backend the composer drives. The app's
7
+ * `@startsimpli/api` `AttachmentsApi` satisfies this shape WITHOUT this package
8
+ * importing `@startsimpli/api` — `@startsimpli/ui` stays dependency-free.
9
+ */
10
+ export interface AttachmentUploader {
11
+ uploadAttachment(
12
+ documentId: string,
13
+ file: File,
14
+ opts?: { purpose?: string; onProgress?: (fraction: number) => void }
15
+ ): Promise<{ id: string; fileUrl: string | null }>
16
+ deleteAttachment(documentId: string, attachmentId: string): Promise<void>
17
+ }
18
+
19
+ /** A successfully-uploaded reference, surfaced to the app via `onAttachmentsChange`. */
20
+ export interface UploadedAttachmentRef {
21
+ /** Server attachment id. */
22
+ id: string
23
+ fileUrl: string | null
24
+ name: string
25
+ }
26
+
27
+ /** Hard ceiling on a single upload so a stalled request surfaces as an error
28
+ * (an error chip) instead of sitting at 0% forever. Downscaled references are
29
+ * small, so this is generous headroom, not a normal-case limit. */
30
+ const UPLOAD_TIMEOUT_MS = 90_000
31
+
32
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
33
+ return new Promise<T>((resolve, reject) => {
34
+ const t = setTimeout(() => reject(new Error('upload timed out')), ms)
35
+ promise.then(
36
+ (v) => {
37
+ clearTimeout(t)
38
+ resolve(v)
39
+ },
40
+ (e) => {
41
+ clearTimeout(t)
42
+ reject(e)
43
+ }
44
+ )
45
+ })
46
+ }
47
+
48
+ interface TrackedAttachment {
49
+ /** Stable client id for the chip (also the composer key). */
50
+ id: string
51
+ file: File
52
+ name: string
53
+ sizeBytes: number
54
+ thumbnailUrl?: string
55
+ /** `queued` = held client-side until a documentId exists. */
56
+ status: 'queued' | 'uploading' | 'ready' | 'error'
57
+ progress: number
58
+ /** Server attachment id once uploaded (for delete). */
59
+ serverId?: string
60
+ /** Server asset url once uploaded. */
61
+ fileUrl?: string | null
62
+ /** Failure reason when status==='error'. */
63
+ error?: string
64
+ }
65
+
66
+ function isRealId(id: string | undefined): id is string {
67
+ return !!id && id !== 'new'
68
+ }
69
+
70
+ let seq = 0
71
+ const nextChipId = () => `pa-${Date.now().toString(36)}-${++seq}`
72
+
73
+ function makeThumbnail(file: File): string | undefined {
74
+ if (!file.type.startsWith('image/')) return undefined
75
+ try {
76
+ return URL.createObjectURL(file)
77
+ } catch {
78
+ return undefined
79
+ }
80
+ }
81
+
82
+ export interface UseComposerUploadsOptions {
83
+ /** Upload purpose tag forwarded to the backend. Default 'reference'. */
84
+ purpose?: string
85
+ /** Fired whenever the set of successfully-uploaded attachments changes. */
86
+ onAttachmentsChange?: (refs: UploadedAttachmentRef[]) => void
87
+ }
88
+
89
+ export interface UseComposerUploadsResult {
90
+ /** Chips for the composer's controlled `attachments` rendering. */
91
+ attachments: PendingAttachment[]
92
+ /** Add files (attach button / drop / paste). */
93
+ attachFiles: (files: File[]) => void
94
+ /** Remove a chip by id (deletes server-side if already uploaded). */
95
+ removeAttachment: (id: string) => void
96
+ /** Retry a failed chip's upload. */
97
+ retryAttachment: (id: string) => void
98
+ /** Clear all chips (e.g. after a send that consumed them). */
99
+ clear: () => void
100
+ /** True while any chip is still queued/uploading. */
101
+ isUploading: boolean
102
+ }
103
+
104
+ /**
105
+ * App-agnostic upload-state manager for chat reference attachments. Bridges a
106
+ * document-scoped {@link AttachmentUploader} to the composer's chip rendering,
107
+ * so the composer can self-manage uploads when an `uploader` prop is supplied.
108
+ *
109
+ * NEW-DECK handling: the upload endpoint is document-scoped, but a brand-new
110
+ * deck has no document until the first turn creates it. When `documentId` is
111
+ * absent (or 'new'), selected files are held client-side as queued chips (shown
112
+ * as `uploading`/indeterminate). The moment a real `documentId` arrives — i.e.
113
+ * the first turn created the document — every queued file auto-uploads onto it.
114
+ */
115
+ export function useComposerUploads(
116
+ uploader: AttachmentUploader,
117
+ documentId: string | undefined,
118
+ options: UseComposerUploadsOptions = {}
119
+ ): UseComposerUploadsResult {
120
+ const { purpose = 'reference', onAttachmentsChange } = options
121
+ const [items, setItems] = React.useState<TrackedAttachment[]>([])
122
+ const itemsRef = React.useRef(items)
123
+ itemsRef.current = items
124
+ // Track which chips have an upload in flight to avoid double-uploading.
125
+ const inFlight = React.useRef<Set<string>>(new Set())
126
+
127
+ const patch = React.useCallback((id: string, next: Partial<TrackedAttachment>) => {
128
+ setItems((cur) => cur.map((it) => (it.id === id ? { ...it, ...next } : it)))
129
+ }, [])
130
+
131
+ const upload = React.useCallback(
132
+ async (item: TrackedAttachment, docId: string) => {
133
+ if (inFlight.current.has(item.id)) return
134
+ inFlight.current.add(item.id)
135
+ patch(item.id, { status: 'uploading', progress: 0, error: undefined })
136
+ try {
137
+ // Shrink big reference images before upload (backend normalizes to
138
+ // ~1536px anyway); best-effort, falls back to the original.
139
+ const file = await downscaleImage(item.file)
140
+ const res = await withTimeout(
141
+ uploader.uploadAttachment(docId, file, {
142
+ purpose,
143
+ onProgress: (fraction: number) => patch(item.id, { progress: fraction }),
144
+ }),
145
+ UPLOAD_TIMEOUT_MS
146
+ )
147
+ patch(item.id, { status: 'ready', progress: 1, serverId: res.id, fileUrl: res.fileUrl })
148
+ } catch (err) {
149
+ patch(item.id, {
150
+ status: 'error',
151
+ error: err instanceof Error ? err.message : 'Upload failed',
152
+ })
153
+ } finally {
154
+ inFlight.current.delete(item.id)
155
+ }
156
+ },
157
+ [uploader, patch, purpose]
158
+ )
159
+
160
+ const attachFiles = React.useCallback(
161
+ (files: File[]) => {
162
+ if (files.length === 0) return
163
+ const docId = documentId
164
+ const added: TrackedAttachment[] = files.map((file) => ({
165
+ id: nextChipId(),
166
+ file,
167
+ name: file.name,
168
+ sizeBytes: file.size,
169
+ thumbnailUrl: makeThumbnail(file),
170
+ // No document yet → hold queued; otherwise upload right away.
171
+ status: isRealId(docId) ? 'uploading' : 'queued',
172
+ progress: 0,
173
+ }))
174
+ setItems((cur) => [...cur, ...added])
175
+ if (isRealId(docId)) {
176
+ added.forEach((it) => void upload(it, docId))
177
+ }
178
+ },
179
+ [documentId, upload]
180
+ )
181
+
182
+ // NEW-DECK: when a real documentId arrives, flush any queued files onto it.
183
+ React.useEffect(() => {
184
+ if (!isRealId(documentId)) return
185
+ itemsRef.current
186
+ .filter((it) => it.status === 'queued')
187
+ .forEach((it) => void upload(it, documentId))
188
+ }, [documentId, upload])
189
+
190
+ /** Retry a failed chip's upload (no-op unless it's errored + a doc exists). */
191
+ const retryAttachment = React.useCallback(
192
+ (id: string) => {
193
+ const item = itemsRef.current.find((it) => it.id === id)
194
+ if (!item || item.status !== 'error') return
195
+ if (isRealId(documentId)) {
196
+ void upload(item, documentId)
197
+ } else {
198
+ // No document yet → requeue; the documentId effect will flush it.
199
+ patch(id, { status: 'queued', progress: 0, error: undefined })
200
+ }
201
+ },
202
+ [documentId, upload, patch]
203
+ )
204
+
205
+ const removeAttachment = React.useCallback(
206
+ (id: string) => {
207
+ const item = itemsRef.current.find((it) => it.id === id)
208
+ setItems((cur) => cur.filter((it) => it.id !== id))
209
+ if (item?.thumbnailUrl) {
210
+ try {
211
+ URL.revokeObjectURL(item.thumbnailUrl)
212
+ } catch {
213
+ /* noop */
214
+ }
215
+ }
216
+ // If it was already uploaded, delete it server-side too.
217
+ if (item?.serverId && isRealId(documentId)) {
218
+ void uploader.deleteAttachment(documentId, item.serverId)
219
+ }
220
+ },
221
+ [uploader, documentId]
222
+ )
223
+
224
+ const clear = React.useCallback(() => {
225
+ itemsRef.current.forEach((it) => {
226
+ if (it.thumbnailUrl) {
227
+ try {
228
+ URL.revokeObjectURL(it.thumbnailUrl)
229
+ } catch {
230
+ /* noop */
231
+ }
232
+ }
233
+ })
234
+ setItems([])
235
+ }, [])
236
+
237
+ // Revoke object urls on unmount.
238
+ React.useEffect(
239
+ () => () => {
240
+ itemsRef.current.forEach((it) => {
241
+ if (it.thumbnailUrl) {
242
+ try {
243
+ URL.revokeObjectURL(it.thumbnailUrl)
244
+ } catch {
245
+ /* noop */
246
+ }
247
+ }
248
+ })
249
+ },
250
+ []
251
+ )
252
+
253
+ const attachments: PendingAttachment[] = React.useMemo(
254
+ () =>
255
+ items.map((it) => ({
256
+ id: it.id,
257
+ name: it.name,
258
+ // A queued chip reads as uploading (indeterminate) to the user.
259
+ status: it.status === 'queued' ? 'uploading' : it.status,
260
+ progress: it.status === 'queued' ? 0 : it.progress,
261
+ thumbnailUrl: it.thumbnailUrl,
262
+ sizeBytes: it.sizeBytes,
263
+ error: it.error,
264
+ })),
265
+ [items]
266
+ )
267
+
268
+ // Surface the uploaded refs to the app whenever the ready set changes.
269
+ const readyRefs = React.useMemo<UploadedAttachmentRef[]>(
270
+ () =>
271
+ items
272
+ .filter((it): it is TrackedAttachment & { serverId: string } => it.status === 'ready' && !!it.serverId)
273
+ .map((it) => ({ id: it.serverId, fileUrl: it.fileUrl ?? null, name: it.name })),
274
+ [items]
275
+ )
276
+ const onChangeRef = React.useRef(onAttachmentsChange)
277
+ onChangeRef.current = onAttachmentsChange
278
+ React.useEffect(() => {
279
+ onChangeRef.current?.(readyRefs)
280
+ }, [readyRefs])
281
+
282
+ const isUploading = items.some((it) => it.status === 'queued' || it.status === 'uploading')
283
+
284
+ return { attachments, attachFiles, removeAttachment, retryAttachment, clear, isUploading }
285
+ }
@@ -26,7 +26,12 @@ export function SlideCanvas({ slide, onRetry, renderStructured, className }: Sli
26
26
  return (
27
27
  <div
28
28
  className={cn(
29
- 'relative mx-auto aspect-video w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-white shadow-sm',
29
+ // Fit the largest 16:9 box inside the stage on BOTH axes: drive size
30
+ // from the stage HEIGHT (h-full) so a wide pane can't make the card
31
+ // taller than the stage and bleed over the toolbar; max-w-full clamps
32
+ // width on tall/narrow panes. object-contain on the img handles any
33
+ // residual letterboxing.
34
+ 'relative mx-auto aspect-video h-full max-h-full w-auto max-w-full overflow-hidden rounded-lg border border-border bg-white shadow-sm',
30
35
  className
31
36
  )}
32
37
  data-status={status}