@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
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen, fireEvent } from '@testing-library/react'
|
|
1
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
2
2
|
import { ChatMessage } from '../chat/ChatMessage'
|
|
3
3
|
import { ChatThread } from '../chat/ChatThread'
|
|
4
4
|
import { ChatComposer } from '../chat/ChatComposer'
|
|
@@ -36,6 +36,28 @@ describe('ChatMessage', () => {
|
|
|
36
36
|
expect(screen.getByText(/generateSlide/i)).toBeInTheDocument()
|
|
37
37
|
expect(screen.getByText('Generating slide 1…')).toBeInTheDocument()
|
|
38
38
|
})
|
|
39
|
+
|
|
40
|
+
it('renders message attachment chips (image thumbnail + file chip)', () => {
|
|
41
|
+
render(
|
|
42
|
+
<ChatMessage
|
|
43
|
+
message={{
|
|
44
|
+
id: '4',
|
|
45
|
+
role: 'user',
|
|
46
|
+
content: 'Match this brand',
|
|
47
|
+
attachments: [
|
|
48
|
+
{ id: 'att-1', fileUrl: 'logo.png', filename: 'logo.png', contentType: 'image/png' },
|
|
49
|
+
{ id: 'att-2', fileUrl: 'deck.pdf', filename: 'deck.pdf', contentType: 'application/pdf' },
|
|
50
|
+
],
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
// image attachment renders an <img> thumbnail with its filename as alt
|
|
55
|
+
const img = screen.getByAltText('logo.png') as HTMLImageElement
|
|
56
|
+
expect(img).toBeInTheDocument()
|
|
57
|
+
expect(img.src).toContain('logo.png')
|
|
58
|
+
// non-image attachment renders its filename as text
|
|
59
|
+
expect(screen.getByText('deck.pdf')).toBeInTheDocument()
|
|
60
|
+
})
|
|
39
61
|
})
|
|
40
62
|
|
|
41
63
|
describe('ChatThread', () => {
|
|
@@ -62,6 +84,23 @@ describe('ChatThread', () => {
|
|
|
62
84
|
rerender(<ChatThread messages={[...messages, { id: 'd', role: 'user', content: 'Fourth' }]} />)
|
|
63
85
|
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
|
|
64
86
|
})
|
|
87
|
+
|
|
88
|
+
it('shows an animated working indicator when busy (startsim-ku8o)', () => {
|
|
89
|
+
render(<ChatThread messages={messages} busy busyLabel="Working on your deck…" />)
|
|
90
|
+
expect(screen.getByTestId('chat-working-indicator')).toBeInTheDocument()
|
|
91
|
+
expect(screen.getByText('Working on your deck…')).toBeInTheDocument()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('hides the working indicator when not busy', () => {
|
|
95
|
+
render(<ChatThread messages={messages} />)
|
|
96
|
+
expect(screen.queryByTestId('chat-working-indicator')).not.toBeInTheDocument()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('shows the working indicator even when the thread is empty (busy overrides empty state)', () => {
|
|
100
|
+
render(<ChatThread messages={[]} busy emptyState={<div>Start chatting</div>} />)
|
|
101
|
+
expect(screen.getByTestId('chat-working-indicator')).toBeInTheDocument()
|
|
102
|
+
expect(screen.queryByText('Start chatting')).not.toBeInTheDocument()
|
|
103
|
+
})
|
|
65
104
|
})
|
|
66
105
|
|
|
67
106
|
describe('ChatComposer', () => {
|
|
@@ -126,4 +165,152 @@ describe('ChatComposer', () => {
|
|
|
126
165
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
127
166
|
expect(onSubmit).not.toHaveBeenCalled()
|
|
128
167
|
})
|
|
168
|
+
|
|
169
|
+
describe('attachments', () => {
|
|
170
|
+
function makeFile(name: string, type = 'image/png') {
|
|
171
|
+
return new File(['data'], name, { type })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
it('fires onAttachFiles when files are picked via the attach button input', () => {
|
|
175
|
+
const onAttachFiles = jest.fn()
|
|
176
|
+
render(<ChatComposer onSubmit={jest.fn()} onAttachFiles={onAttachFiles} />)
|
|
177
|
+
// The attach button is present and labelled
|
|
178
|
+
expect(screen.getByRole('button', { name: /attach/i })).toBeInTheDocument()
|
|
179
|
+
const input = screen.getByTestId('chat-attach-input') as HTMLInputElement
|
|
180
|
+
fireEvent.change(input, { target: { files: [makeFile('a.png'), makeFile('b.pdf', 'application/pdf')] } })
|
|
181
|
+
expect(onAttachFiles).toHaveBeenCalledTimes(1)
|
|
182
|
+
expect(onAttachFiles.mock.calls[0][0]).toHaveLength(2)
|
|
183
|
+
expect(onAttachFiles.mock.calls[0][0][0].name).toBe('a.png')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('does NOT render an attach button when onAttachFiles is omitted', () => {
|
|
187
|
+
render(<ChatComposer onSubmit={jest.fn()} />)
|
|
188
|
+
expect(screen.queryByRole('button', { name: /attach/i })).not.toBeInTheDocument()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('renders chips for controlled attachments with a remove control', () => {
|
|
192
|
+
const onRemoveAttachment = jest.fn()
|
|
193
|
+
render(
|
|
194
|
+
<ChatComposer
|
|
195
|
+
onSubmit={jest.fn()}
|
|
196
|
+
onAttachFiles={jest.fn()}
|
|
197
|
+
onRemoveAttachment={onRemoveAttachment}
|
|
198
|
+
attachments={[
|
|
199
|
+
{ id: 'p1', name: 'brand.png', status: 'ready', thumbnailUrl: 'blob:brand', sizeBytes: 2048 },
|
|
200
|
+
{ id: 'p2', name: 'notes.pdf', status: 'ready', sizeBytes: 1024 },
|
|
201
|
+
]}
|
|
202
|
+
/>
|
|
203
|
+
)
|
|
204
|
+
expect(screen.getByText('notes.pdf')).toBeInTheDocument()
|
|
205
|
+
// image chip uses thumbnail with the name as alt
|
|
206
|
+
expect(screen.getByAltText('brand.png')).toBeInTheDocument()
|
|
207
|
+
fireEvent.click(screen.getByRole('button', { name: /remove brand\.png/i }))
|
|
208
|
+
expect(onRemoveAttachment).toHaveBeenCalledWith('p1')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('shows upload progress while a chip is uploading and an error state on failure', () => {
|
|
212
|
+
render(
|
|
213
|
+
<ChatComposer
|
|
214
|
+
onSubmit={jest.fn()}
|
|
215
|
+
onAttachFiles={jest.fn()}
|
|
216
|
+
attachments={[
|
|
217
|
+
{ id: 'u1', name: 'big.pptx', status: 'uploading', progress: 0.42 },
|
|
218
|
+
{ id: 'e1', name: 'bad.png', status: 'error' },
|
|
219
|
+
]}
|
|
220
|
+
/>
|
|
221
|
+
)
|
|
222
|
+
const uploading = screen.getByTestId('chat-attachment-u1')
|
|
223
|
+
expect(uploading).toHaveAttribute('data-status', 'uploading')
|
|
224
|
+
expect(uploading.querySelector('[role="progressbar"]')).toBeInTheDocument()
|
|
225
|
+
const errored = screen.getByTestId('chat-attachment-e1')
|
|
226
|
+
expect(errored).toHaveAttribute('data-status', 'error')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('still submits text with attachments present (attachments are presentation-only)', () => {
|
|
230
|
+
const onSubmit = jest.fn()
|
|
231
|
+
render(
|
|
232
|
+
<ChatComposer
|
|
233
|
+
onSubmit={onSubmit}
|
|
234
|
+
onAttachFiles={jest.fn()}
|
|
235
|
+
attachments={[{ id: 'p1', name: 'a.png', status: 'ready' }]}
|
|
236
|
+
/>
|
|
237
|
+
)
|
|
238
|
+
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'use this' } })
|
|
239
|
+
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
|
|
240
|
+
expect(onSubmit).toHaveBeenCalledWith('use this')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('routes dropped files to onAttachFiles', () => {
|
|
244
|
+
const onAttachFiles = jest.fn()
|
|
245
|
+
const { container } = render(
|
|
246
|
+
<ChatComposer onSubmit={jest.fn()} onAttachFiles={onAttachFiles} />
|
|
247
|
+
)
|
|
248
|
+
const root = container.firstChild as HTMLElement
|
|
249
|
+
fireEvent.drop(root, { dataTransfer: { files: [makeFile('dropped.png')] } })
|
|
250
|
+
expect(onAttachFiles).toHaveBeenCalledTimes(1)
|
|
251
|
+
expect(onAttachFiles.mock.calls[0][0][0].name).toBe('dropped.png')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('routes pasted image/file data to onAttachFiles', () => {
|
|
255
|
+
const onAttachFiles = jest.fn()
|
|
256
|
+
render(<ChatComposer onSubmit={jest.fn()} onAttachFiles={onAttachFiles} />)
|
|
257
|
+
const textarea = screen.getByRole('textbox')
|
|
258
|
+
const file = makeFile('pasted.png')
|
|
259
|
+
fireEvent.paste(textarea, {
|
|
260
|
+
clipboardData: { files: [file], items: [{ kind: 'file', type: 'image/png' }] },
|
|
261
|
+
})
|
|
262
|
+
expect(onAttachFiles).toHaveBeenCalledTimes(1)
|
|
263
|
+
expect(onAttachFiles.mock.calls[0][0][0].name).toBe('pasted.png')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('self-managed uploader mode (startsim-dd9o)', () => {
|
|
267
|
+
beforeAll(() => {
|
|
268
|
+
if (!('createObjectURL' in URL)) {
|
|
269
|
+
// @ts-expect-error test shim
|
|
270
|
+
URL.createObjectURL = jest.fn(() => 'blob:preview')
|
|
271
|
+
}
|
|
272
|
+
if (!('revokeObjectURL' in URL)) {
|
|
273
|
+
// @ts-expect-error test shim
|
|
274
|
+
URL.revokeObjectURL = jest.fn()
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
function fakeUploader() {
|
|
279
|
+
return {
|
|
280
|
+
uploadAttachment: jest.fn(async (_id: string, file: File) => ({
|
|
281
|
+
id: `srv-${file.name}`,
|
|
282
|
+
fileUrl: `https://cdn/${file.name}`,
|
|
283
|
+
})),
|
|
284
|
+
deleteAttachment: jest.fn(async () => undefined),
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
it('uploads picked files through the uploader and shows a ready chip — no controlled props needed', async () => {
|
|
289
|
+
const uploader = fakeUploader()
|
|
290
|
+
render(<ChatComposer onSubmit={jest.fn()} uploader={uploader} documentId="doc-1" />)
|
|
291
|
+
const input = screen.getByTestId('chat-attach-input') as HTMLInputElement
|
|
292
|
+
fireEvent.change(input, { target: { files: [makeFile('hero.png')] } })
|
|
293
|
+
await waitFor(() =>
|
|
294
|
+
expect(uploader.uploadAttachment).toHaveBeenCalledWith('doc-1', expect.any(File), expect.any(Object))
|
|
295
|
+
)
|
|
296
|
+
await waitFor(() => {
|
|
297
|
+
const chips = screen.getByTestId('chat-attachment-chips')
|
|
298
|
+
expect(chips.querySelector('[data-status="ready"]')).toBeInTheDocument()
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('shows the attach affordance even without onAttachFiles when an uploader is present', () => {
|
|
303
|
+
render(<ChatComposer onSubmit={jest.fn()} uploader={fakeUploader()} documentId="doc-1" />)
|
|
304
|
+
expect(screen.getByRole('button', { name: /attach/i })).toBeInTheDocument()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('still submits text in self-managed mode', () => {
|
|
308
|
+
const onSubmit = jest.fn()
|
|
309
|
+
render(<ChatComposer onSubmit={onSubmit} uploader={fakeUploader()} documentId="doc-1" />)
|
|
310
|
+
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'use these' } })
|
|
311
|
+
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
|
|
312
|
+
expect(onSubmit).toHaveBeenCalledWith('use these')
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
})
|
|
129
316
|
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import {
|
|
3
|
+
DocumentStatusBadge,
|
|
4
|
+
DOCUMENT_STATUS_CONFIG,
|
|
5
|
+
type DocumentStatus,
|
|
6
|
+
} from '../badge/DocumentStatusBadge'
|
|
7
|
+
|
|
8
|
+
describe('DocumentStatusBadge', () => {
|
|
9
|
+
const cases: Array<{ status: DocumentStatus; label: string; tone: string }> = [
|
|
10
|
+
{ status: 'draft', label: 'Draft', tone: 'bg-gray-100' },
|
|
11
|
+
{ status: 'generating', label: 'Generating', tone: 'bg-amber-100' },
|
|
12
|
+
{ status: 'ready', label: 'Ready', tone: 'bg-green-100' },
|
|
13
|
+
{ status: 'error', label: 'Error', tone: 'bg-red-100' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
it.each(cases)('renders $status with the spec label and tone', ({ status, label, tone }) => {
|
|
17
|
+
render(<DocumentStatusBadge status={status} />)
|
|
18
|
+
const badge = screen.getByText(label)
|
|
19
|
+
expect(badge).toBeInTheDocument()
|
|
20
|
+
expect(badge).toHaveClass(tone)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('exposes the canonical config mapping (single source of truth)', () => {
|
|
24
|
+
expect(DOCUMENT_STATUS_CONFIG.ready.label).toBe('Ready')
|
|
25
|
+
expect(DOCUMENT_STATUS_CONFIG.generating.className).toContain('amber')
|
|
26
|
+
expect(DOCUMENT_STATUS_CONFIG.error.className).toContain('red')
|
|
27
|
+
expect(DOCUMENT_STATUS_CONFIG.draft.className).toContain('gray')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders a status dot by default and omits it when showDot=false', () => {
|
|
31
|
+
const { container, rerender } = render(<DocumentStatusBadge status="ready" />)
|
|
32
|
+
expect(container.querySelector('span[aria-hidden="true"]')).not.toBeNull()
|
|
33
|
+
|
|
34
|
+
rerender(<DocumentStatusBadge status="ready" showDot={false} />)
|
|
35
|
+
expect(container.querySelector('span[aria-hidden="true"]')).toBeNull()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('uses a pulsing dot for generating', () => {
|
|
39
|
+
const { container } = render(<DocumentStatusBadge status="generating" />)
|
|
40
|
+
const dot = container.querySelector('span[aria-hidden="true"]')
|
|
41
|
+
expect(dot?.className).toContain('animate-pulse')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('honors the size prop', () => {
|
|
45
|
+
render(<DocumentStatusBadge status="ready" size="sm" />)
|
|
46
|
+
expect(screen.getByText('Ready')).toHaveClass('px-2')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { StatusBadge, type StatusBadgeConfig } from './StatusBadge'
|
|
5
|
+
|
|
6
|
+
/** Lifecycle status of a generated document (matches @startsimpli/api DocumentStatus). */
|
|
7
|
+
export type DocumentStatus = 'draft' | 'generating' | 'ready' | 'error'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Canonical color + label mapping for document lifecycle status — the single
|
|
11
|
+
* source of truth shared by the deck list cards and the reopen toolbar (per the
|
|
12
|
+
* present-web reopen-ux-spec §1.2). Generic enough that any documents-backed app
|
|
13
|
+
* (vault, market) can reuse it for a status pill.
|
|
14
|
+
*/
|
|
15
|
+
export const DOCUMENT_STATUS_CONFIG: Record<DocumentStatus, StatusBadgeConfig> = {
|
|
16
|
+
draft: {
|
|
17
|
+
label: 'Draft',
|
|
18
|
+
className: 'gap-1.5 bg-gray-100 text-gray-600',
|
|
19
|
+
},
|
|
20
|
+
generating: {
|
|
21
|
+
label: 'Generating',
|
|
22
|
+
className: 'gap-1.5 bg-amber-100 text-amber-700',
|
|
23
|
+
},
|
|
24
|
+
ready: {
|
|
25
|
+
label: 'Ready',
|
|
26
|
+
className: 'gap-1.5 bg-green-100 text-green-700',
|
|
27
|
+
},
|
|
28
|
+
error: {
|
|
29
|
+
label: 'Error',
|
|
30
|
+
className: 'gap-1.5 bg-red-100 text-red-700',
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Per-status dot treatment: hollow for draft, pulsing for generating, solid otherwise. */
|
|
35
|
+
const DOT_CLASS: Record<DocumentStatus, string> = {
|
|
36
|
+
draft: 'border border-current bg-transparent',
|
|
37
|
+
generating: 'bg-current animate-pulse',
|
|
38
|
+
ready: 'bg-current',
|
|
39
|
+
error: 'bg-current',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DocumentStatusBadgeProps {
|
|
43
|
+
status: DocumentStatus
|
|
44
|
+
size?: 'sm' | 'md'
|
|
45
|
+
/** Show the leading status dot. Defaults to true. */
|
|
46
|
+
showDot?: boolean
|
|
47
|
+
className?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Status pill for a generated document. Wraps the generic `StatusBadge` with the
|
|
52
|
+
* canonical document-status config and a leading dot whose treatment encodes the
|
|
53
|
+
* state (hollow draft / pulsing generating / solid ready+error).
|
|
54
|
+
*/
|
|
55
|
+
export function DocumentStatusBadge({
|
|
56
|
+
status,
|
|
57
|
+
size = 'md',
|
|
58
|
+
showDot = true,
|
|
59
|
+
className,
|
|
60
|
+
}: DocumentStatusBadgeProps) {
|
|
61
|
+
return (
|
|
62
|
+
<StatusBadge<DocumentStatus>
|
|
63
|
+
status={status}
|
|
64
|
+
config={DOCUMENT_STATUS_CONFIG}
|
|
65
|
+
size={size}
|
|
66
|
+
className={className}
|
|
67
|
+
>
|
|
68
|
+
{showDot ? (
|
|
69
|
+
<span
|
|
70
|
+
aria-hidden="true"
|
|
71
|
+
className={`mr-1 inline-block h-1.5 w-1.5 shrink-0 rounded-full ${DOT_CLASS[status] ?? 'bg-current'}`}
|
|
72
|
+
/>
|
|
73
|
+
) : null}
|
|
74
|
+
</StatusBadge>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -11,6 +11,8 @@ export interface StatusBadgeProps<T extends string> {
|
|
|
11
11
|
config: Record<T, StatusBadgeConfig>
|
|
12
12
|
size?: 'sm' | 'md'
|
|
13
13
|
className?: string
|
|
14
|
+
/** Optional leading content (e.g. a status dot) rendered before the label. */
|
|
15
|
+
children?: React.ReactNode
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
const sizeClasses = {
|
|
@@ -23,6 +25,7 @@ export function StatusBadge<T extends string>({
|
|
|
23
25
|
config,
|
|
24
26
|
size = 'md',
|
|
25
27
|
className,
|
|
28
|
+
children,
|
|
26
29
|
}: StatusBadgeProps<T>) {
|
|
27
30
|
const entry = config[status]
|
|
28
31
|
|
|
@@ -35,6 +38,7 @@ export function StatusBadge<T extends string>({
|
|
|
35
38
|
className
|
|
36
39
|
)}
|
|
37
40
|
>
|
|
41
|
+
{children}
|
|
38
42
|
{status}
|
|
39
43
|
</span>
|
|
40
44
|
)
|
|
@@ -49,6 +53,7 @@ export function StatusBadge<T extends string>({
|
|
|
49
53
|
className
|
|
50
54
|
)}
|
|
51
55
|
>
|
|
56
|
+
{children}
|
|
52
57
|
{entry.label}
|
|
53
58
|
</span>
|
|
54
59
|
)
|
|
@@ -3,3 +3,6 @@ export type { StatusBadgeProps, StatusBadgeConfig } from './StatusBadge'
|
|
|
3
3
|
|
|
4
4
|
export { StageBadge } from './StageBadge'
|
|
5
5
|
export type { StageBadgeProps, StageBadgeConfig } from './StageBadge'
|
|
6
|
+
|
|
7
|
+
export { DocumentStatusBadge, DOCUMENT_STATUS_CONFIG } from './DocumentStatusBadge'
|
|
8
|
+
export type { DocumentStatusBadgeProps, DocumentStatus } from './DocumentStatusBadge'
|