@open-mercato/ui 0.6.2-develop.3446.1.bd060c6017 → 0.6.2-develop.3467.1.2a1818709d

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/src/ai/AiChat.tsx CHANGED
@@ -16,12 +16,14 @@ import {
16
16
  CircleDashed,
17
17
  Copy,
18
18
  Database,
19
+ Download,
19
20
  FileQuestion,
20
21
  GitBranch,
21
22
  Handshake,
22
23
  Lightbulb,
23
24
  ListChecks,
24
25
  Loader2,
26
+ Maximize2,
25
27
  Paperclip,
26
28
  Plus,
27
29
  Search,
@@ -38,6 +40,13 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
38
40
  import { cn } from '@open-mercato/shared/lib/utils'
39
41
  import { Alert, AlertDescription, AlertTitle } from '../primitives/alert'
40
42
  import { Button } from '../primitives/button'
43
+ import {
44
+ Dialog,
45
+ DialogContent,
46
+ DialogDescription,
47
+ DialogHeader,
48
+ DialogTitle,
49
+ } from '../primitives/dialog'
41
50
  import { IconButton } from '../primitives/icon-button'
42
51
  import { Label } from '../primitives/label'
43
52
  import { Textarea } from '../primitives/textarea'
@@ -638,6 +647,128 @@ function ReasoningPanel({ text, streaming }: { text: string; streaming: boolean
638
647
  )
639
648
  }
640
649
 
650
+ function getAttachmentImageUrl(file: AiChatMessageFile): string | null {
651
+ if (file.id) return `/api/attachments/image/${encodeURIComponent(file.id)}`
652
+ return file.previewUrl ?? null
653
+ }
654
+
655
+ function getAttachmentDownloadUrl(file: AiChatMessageFile): string | null {
656
+ if (file.id) return `/api/attachments/file/${encodeURIComponent(file.id)}?download=1`
657
+ return file.previewUrl ?? null
658
+ }
659
+
660
+ function MessageFileAttachment({ file }: { file: AiChatMessageFile }) {
661
+ const t = useT()
662
+ const [zoomOpen, setZoomOpen] = React.useState(false)
663
+ const imageUrl = file.previewUrl ? getAttachmentImageUrl(file) : null
664
+ const downloadUrl = getAttachmentDownloadUrl(file)
665
+ const downloadLabel = t('ai_assistant.chat.downloadFile', 'Download {name}').replace(
666
+ '{name}',
667
+ file.name,
668
+ )
669
+ const zoomLabel = t('ai_assistant.chat.zoomImage', 'Open {name} preview').replace(
670
+ '{name}',
671
+ file.name,
672
+ )
673
+
674
+ if (!file.previewUrl) {
675
+ return (
676
+ <span className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs">
677
+ <Paperclip className="size-3 shrink-0" aria-hidden />
678
+ <span className="max-w-[180px] truncate">{file.name}</span>
679
+ {downloadUrl ? (
680
+ <IconButton
681
+ asChild
682
+ variant="ghost"
683
+ size="xs"
684
+ className="-mr-1 size-5 shrink-0"
685
+ >
686
+ <a href={downloadUrl} download={file.name} aria-label={downloadLabel}>
687
+ <Download className="size-3" aria-hidden />
688
+ </a>
689
+ </IconButton>
690
+ ) : null}
691
+ </span>
692
+ )
693
+ }
694
+
695
+ return (
696
+ <>
697
+ <div className="group/file relative max-w-full">
698
+ <button
699
+ type="button"
700
+ className="block max-w-full rounded-md border border-border bg-muted/30 focus-visible:outline-none focus-visible:shadow-focus"
701
+ onClick={() => setZoomOpen(true)}
702
+ aria-label={zoomLabel}
703
+ data-ai-chat-image-preview=""
704
+ >
705
+ <img
706
+ src={file.previewUrl}
707
+ alt={file.name}
708
+ className="max-h-32 max-w-[200px] rounded-md object-cover"
709
+ />
710
+ <span className="pointer-events-none absolute right-1 top-1 inline-flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground opacity-0 shadow-xs transition-opacity group-hover/file:opacity-100 group-focus-within/file:opacity-100">
711
+ <Maximize2 className="size-3.5" aria-hidden />
712
+ </span>
713
+ </button>
714
+ {downloadUrl ? (
715
+ <IconButton
716
+ asChild
717
+ variant="white"
718
+ size="xs"
719
+ className="absolute bottom-1 right-1 opacity-0 shadow-xs transition-opacity group-hover/file:opacity-100 group-focus-within/file:opacity-100"
720
+ >
721
+ <a
722
+ href={downloadUrl}
723
+ download={file.name}
724
+ aria-label={downloadLabel}
725
+ onClick={(event) => event.stopPropagation()}
726
+ data-ai-chat-file-download=""
727
+ >
728
+ <Download className="size-3.5" aria-hidden />
729
+ </a>
730
+ </IconButton>
731
+ ) : null}
732
+ </div>
733
+
734
+ <Dialog open={zoomOpen} onOpenChange={setZoomOpen}>
735
+ <DialogContent className="gap-3 p-4 sm:max-w-[min(96vw,960px)]">
736
+ <DialogHeader className="min-w-0 pr-16">
737
+ <DialogTitle className="truncate text-sm">{file.name}</DialogTitle>
738
+ <DialogDescription className="sr-only">
739
+ {t('ai_assistant.chat.imagePreviewDialogDescription', 'Image preview')}
740
+ </DialogDescription>
741
+ </DialogHeader>
742
+ <div className="relative flex min-h-0 items-center justify-center overflow-auto rounded-md bg-muted/30 p-2">
743
+ <img
744
+ src={imageUrl ?? file.previewUrl}
745
+ alt={file.name}
746
+ className="max-h-[75vh] max-w-full object-contain"
747
+ />
748
+ {downloadUrl ? (
749
+ <IconButton
750
+ asChild
751
+ variant="white"
752
+ size="sm"
753
+ className="absolute bottom-3 right-3 shadow-xs"
754
+ >
755
+ <a
756
+ href={downloadUrl}
757
+ download={file.name}
758
+ aria-label={downloadLabel}
759
+ data-ai-chat-file-download=""
760
+ >
761
+ <Download className="size-4" aria-hidden />
762
+ </a>
763
+ </IconButton>
764
+ ) : null}
765
+ </div>
766
+ </DialogContent>
767
+ </Dialog>
768
+ </>
769
+ )
770
+ }
771
+
641
772
  function MessageRow({
642
773
  message,
643
774
  registry,
@@ -737,24 +868,9 @@ function MessageRow({
737
868
  </div>
738
869
  {message.files && message.files.length > 0 ? (
739
870
  <div className="flex flex-wrap gap-2 py-1">
740
- {message.files.map((file, i) =>
741
- file.previewUrl ? (
742
- <img
743
- key={i}
744
- src={file.previewUrl}
745
- alt={file.name}
746
- className="max-h-32 max-w-[200px] rounded-md border border-border object-cover"
747
- />
748
- ) : (
749
- <span
750
- key={i}
751
- className="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs"
752
- >
753
- <Paperclip className="size-3" aria-hidden />
754
- {file.name}
755
- </span>
756
- ),
757
- )}
871
+ {message.files.map((file, i) => (
872
+ <MessageFileAttachment key={`${file.id ?? file.name}:${i}`} file={file} />
873
+ ))}
758
874
  </div>
759
875
  ) : null}
760
876
  {isAssistant && message.reasoning ? (
@@ -1030,10 +1146,9 @@ export function AiChat({
1030
1146
  attachmentId?: string
1031
1147
  /**
1032
1148
  * Base64 data URL of the image (capped by PREVIEW_DATA_URL_MAX_BYTES).
1033
- * Stored on the message so the preview survives a reload — durable
1034
- * server URLs were intentionally avoided because the LLM provider can
1035
- * never reach a localhost dev URL anyway, and HTTP fetches add latency
1036
- * the chat doesn't need.
1149
+ * Stored on the local message for immediate previews; once the transcript
1150
+ * is loaded from server storage the attachment id is used to rebuild a
1151
+ * same-origin thumbnail URL.
1037
1152
  */
1038
1153
  previewDataUrl?: string
1039
1154
  error?: string
@@ -1255,6 +1370,7 @@ export function AiChat({
1255
1370
  const isImage = entry.file.type.startsWith('image/')
1256
1371
  const fallback = isImage ? URL.createObjectURL(entry.file) : undefined
1257
1372
  return {
1373
+ id: entry.attachmentId,
1258
1374
  name: entry.file.name,
1259
1375
  type: entry.file.type,
1260
1376
  previewUrl: isImage ? (entry.previewDataUrl ?? fallback) : undefined,
@@ -4,12 +4,10 @@
4
4
  * Multi-tab AI chat sessions.
5
5
  *
6
6
  * Each agent can have several concurrent conversation threads (sessions).
7
- * The provider stores their *metadata* — id, conversationId, optional
8
- * user-given name, timestamps, status — in localStorage, so closing and
9
- * re-opening the chat pane (or refreshing the page) restores the tabs and
10
- * their history. The actual messages live in `useAiChat`'s per-conversation
11
- * persistence slot keyed by `(agent, conversationId)`, which is why every
12
- * session gets its own UUID even when it shares an agent with the next tab.
7
+ * The provider mirrors session metadata from the server-side conversation
8
+ * store. localStorage remains a backward-compatible cache so existing browser
9
+ * sessions can be imported rather than orphaned, but the server is the source
10
+ * of truth whenever it is reachable.
13
11
  *
14
12
  * Sessions are partitioned into:
15
13
  * - `open` → currently shown in the tab strip
@@ -22,6 +20,12 @@
22
20
  */
23
21
 
24
22
  import * as React from 'react'
23
+ import {
24
+ createAiServerConversation,
25
+ listAiServerConversations,
26
+ updateAiServerConversation,
27
+ type AiServerConversation,
28
+ } from './conversation-store'
25
29
 
26
30
  const STORAGE_KEY = 'om-ai-chat-sessions-v1'
27
31
  const HISTORY_LIMIT = 50
@@ -135,6 +139,49 @@ function writePersisted(state: AiChatSessionsState): void {
135
139
  }
136
140
  }
137
141
 
142
+ function serverConversationToSession(
143
+ conversation: AiServerConversation,
144
+ existing?: AiChatSession,
145
+ ): AiChatSession {
146
+ const createdAt = Date.parse(conversation.createdAt)
147
+ const updatedAt = Date.parse(conversation.lastMessageAt ?? conversation.updatedAt)
148
+ return {
149
+ id: existing?.id ?? conversation.conversationId,
150
+ agentId: conversation.agentId,
151
+ conversationId: conversation.conversationId,
152
+ name: conversation.title ?? existing?.name,
153
+ createdAt: Number.isFinite(createdAt) ? createdAt : existing?.createdAt ?? Date.now(),
154
+ lastUsedAt: Number.isFinite(updatedAt) ? updatedAt : existing?.lastUsedAt ?? Date.now(),
155
+ status: conversation.status,
156
+ }
157
+ }
158
+
159
+ function mergeServerConversations(
160
+ prev: AiChatSessionsState,
161
+ conversations: AiServerConversation[],
162
+ ): AiChatSessionsState {
163
+ const byConversationId = new Map(prev.sessions.map((session) => [session.conversationId, session]))
164
+ const serverSessions = conversations.map((conversation) =>
165
+ serverConversationToSession(conversation, byConversationId.get(conversation.conversationId)),
166
+ )
167
+ const serverIds = new Set(serverSessions.map((session) => session.conversationId))
168
+ const localOnly = prev.sessions.filter((session) => !serverIds.has(session.conversationId))
169
+ const sessions = [...serverSessions, ...localOnly]
170
+ const activeByAgent = { ...prev.activeByAgent }
171
+ const agentIds = new Set(sessions.map((session) => session.agentId))
172
+ for (const agentId of agentIds) {
173
+ const activeId = activeByAgent[agentId]
174
+ const active = sessions.find((session) => session.id === activeId)
175
+ if (active && active.status === 'open') continue
176
+ const nextOpen = sessions
177
+ .filter((session) => session.agentId === agentId && session.status === 'open')
178
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]
179
+ if (nextOpen) activeByAgent[agentId] = nextOpen.id
180
+ else delete activeByAgent[agentId]
181
+ }
182
+ return { sessions, activeByAgent }
183
+ }
184
+
138
185
  export function AiChatSessionsProvider({ children }: { children: React.ReactNode }) {
139
186
  // Hydrate synchronously via a lazy initializer. The previous "empty
140
187
  // state + post-mount load effect" pattern had a window where the
@@ -153,6 +200,17 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
153
200
  writePersisted(state)
154
201
  }, [state])
155
202
 
203
+ React.useEffect(() => {
204
+ let cancelled = false
205
+ void listAiServerConversations({ limit: 100 }).then((conversations) => {
206
+ if (cancelled || !conversations) return
207
+ setState((prev) => mergeServerConversations(prev, conversations))
208
+ })
209
+ return () => {
210
+ cancelled = true
211
+ }
212
+ }, [])
213
+
156
214
  const update = React.useCallback(
157
215
  (mutator: (prev: AiChatSessionsState) => AiChatSessionsState) => {
158
216
  setState((prev) => mutator(prev))
@@ -197,6 +255,10 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
197
255
  lastUsedAt: Date.now(),
198
256
  status: 'open',
199
257
  }
258
+ void createAiServerConversation({
259
+ agentId: session.agentId,
260
+ conversationId: session.conversationId,
261
+ })
200
262
  update((prev) => ({
201
263
  sessions: [...prev.sessions, session],
202
264
  activeByAgent: { ...prev.activeByAgent, [agentId]: session.id },
@@ -206,6 +268,10 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
206
268
 
207
269
  const closeSession = React.useCallback(
208
270
  (sessionId: string) => {
271
+ const serverTarget = state.sessions.find((s) => s.id === sessionId)
272
+ if (serverTarget) {
273
+ void updateAiServerConversation(serverTarget.conversationId, { status: 'closed' })
274
+ }
209
275
  update((prev) => {
210
276
  const target = prev.sessions.find((s) => s.id === sessionId)
211
277
  if (!target) return prev
@@ -227,11 +293,15 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
227
293
  return { sessions, activeByAgent }
228
294
  })
229
295
  },
230
- [update],
296
+ [state.sessions, update],
231
297
  )
232
298
 
233
299
  const reopenSession = React.useCallback(
234
300
  (sessionId: string) => {
301
+ const serverTarget = state.sessions.find((s) => s.id === sessionId)
302
+ if (serverTarget) {
303
+ void updateAiServerConversation(serverTarget.conversationId, { status: 'open' })
304
+ }
235
305
  update((prev) => {
236
306
  const target = prev.sessions.find((s) => s.id === sessionId)
237
307
  if (!target) return prev
@@ -244,7 +314,7 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
244
314
  }
245
315
  })
246
316
  },
247
- [update],
317
+ [state.sessions, update],
248
318
  )
249
319
 
250
320
  const setActiveSession = React.useCallback(
@@ -267,6 +337,12 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
267
337
  const renameSession = React.useCallback(
268
338
  (sessionId: string, name: string) => {
269
339
  const trimmed = name.trim()
340
+ const serverTarget = state.sessions.find((s) => s.id === sessionId)
341
+ if (serverTarget) {
342
+ void updateAiServerConversation(serverTarget.conversationId, {
343
+ title: trimmed.length > 0 ? trimmed : null,
344
+ })
345
+ }
270
346
  update((prev) => ({
271
347
  ...prev,
272
348
  sessions: prev.sessions.map((s) =>
@@ -276,7 +352,7 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
276
352
  ),
277
353
  }))
278
354
  },
279
- [update],
355
+ [state.sessions, update],
280
356
  )
281
357
 
282
358
  const touchSession = React.useCallback(
@@ -334,6 +410,10 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
334
410
  lastUsedAt: Date.now(),
335
411
  status: 'open',
336
412
  }
413
+ void createAiServerConversation({
414
+ agentId: mintedSession.agentId,
415
+ conversationId: mintedSession.conversationId,
416
+ })
337
417
  }
338
418
  resolved = mintedSession
339
419
  return {
@@ -157,6 +157,92 @@ describe('<AiChat> conversationId threading (Step 5.15)', () => {
157
157
  const parsedBody = JSON.parse(init.body as string)
158
158
  expect(parsedBody.conversationId).toBe('conv-body-xyz')
159
159
  })
160
+
161
+ it('hydrates the transcript from server storage for an existing conversation', async () => {
162
+ const originalFetch = globalThis.fetch
163
+ const fetchMock = jest.fn(async () => ({
164
+ ok: true,
165
+ status: 200,
166
+ json: async () => ({
167
+ conversation: {
168
+ conversationId: 'conv-server-xyz',
169
+ agentId: 'customers.account_assistant',
170
+ title: null,
171
+ status: 'open',
172
+ visibility: 'private',
173
+ pageContext: null,
174
+ createdAt: '2026-05-18T10:00:00.000Z',
175
+ updatedAt: '2026-05-18T10:00:00.000Z',
176
+ lastMessageAt: '2026-05-18T10:00:00.000Z',
177
+ importedFromLocalAt: null,
178
+ },
179
+ messages: [
180
+ {
181
+ id: 'server-msg-1',
182
+ clientMessageId: 'client-msg-1',
183
+ role: 'user',
184
+ content: 'Server question',
185
+ uiParts: [],
186
+ attachmentIds: ['att-image-1'],
187
+ files: [
188
+ {
189
+ name: 'IMG_5328.JPEG',
190
+ mimeType: 'image/jpeg',
191
+ },
192
+ ],
193
+ model: null,
194
+ metadata: null,
195
+ createdAt: '2026-05-18T10:00:00.000Z',
196
+ },
197
+ {
198
+ id: 'server-msg-2',
199
+ clientMessageId: 'client-msg-2',
200
+ role: 'assistant',
201
+ content: 'Server answer',
202
+ uiParts: [],
203
+ attachmentIds: [],
204
+ files: [],
205
+ model: null,
206
+ metadata: null,
207
+ createdAt: '2026-05-18T10:00:01.000Z',
208
+ },
209
+ ],
210
+ nextCursor: null,
211
+ }),
212
+ })) as unknown as typeof fetch
213
+ globalThis.fetch = fetchMock
214
+
215
+ try {
216
+ renderWithProviders(
217
+ <AiChat agent="customers.account_assistant" conversationId="conv-server-xyz" />,
218
+ { dict },
219
+ )
220
+
221
+ expect(await screen.findByText('Server question')).toBeInTheDocument()
222
+ const image = await screen.findByRole('img', { name: 'IMG_5328.JPEG' })
223
+ expect(image).toHaveAttribute(
224
+ 'src',
225
+ '/api/attachments/image/att-image-1?width=320&height=320&cropType=contain',
226
+ )
227
+ const download = screen.getByLabelText('Download IMG_5328.JPEG')
228
+ expect(download).toHaveAttribute(
229
+ 'href',
230
+ '/api/attachments/file/att-image-1?download=1',
231
+ )
232
+ fireEvent.click(image)
233
+ expect(await screen.findByRole('dialog')).toBeInTheDocument()
234
+ expect(screen.getByRole('heading', { name: 'IMG_5328.JPEG' })).toBeInTheDocument()
235
+ const images = screen.getAllByRole('img', { name: 'IMG_5328.JPEG' })
236
+ expect(images[1]).toHaveAttribute('src', '/api/attachments/image/att-image-1')
237
+ expect(await screen.findByText('Server answer')).toBeInTheDocument()
238
+ expect(fetchMock).toHaveBeenCalledWith(
239
+ '/api/ai_assistant/ai/conversations/conv-server-xyz?limit=100',
240
+ expect.objectContaining({ credentials: 'include' }),
241
+ )
242
+ } finally {
243
+ globalThis.fetch = originalFetch
244
+ }
245
+ })
160
246
  })
161
247
 
162
248
  // <AiChat> mounts useAgentModels (Phase 4b) which hits /api/ai_assistant/ai/agents/<id>/models