@open-mercato/ui 0.6.2-develop.3461.1.605f31c2c9 → 0.6.2

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 (86) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/AiChat.js +130 -22
  3. package/dist/ai/AiChat.js.map +2 -2
  4. package/dist/ai/AiChatSessions.js +73 -3
  5. package/dist/ai/AiChatSessions.js.map +2 -2
  6. package/dist/ai/conversation-store.js +149 -0
  7. package/dist/ai/conversation-store.js.map +7 -0
  8. package/dist/ai/parts/MutationPreviewCard.js +4 -3
  9. package/dist/ai/parts/MutationPreviewCard.js.map +2 -2
  10. package/dist/ai/parts/MutationResultCard.js +9 -16
  11. package/dist/ai/parts/MutationResultCard.js.map +2 -2
  12. package/dist/ai/useAiChat.js +80 -4
  13. package/dist/ai/useAiChat.js.map +2 -2
  14. package/dist/backend/AuthSessionGuard.js +99 -0
  15. package/dist/backend/AuthSessionGuard.js.map +7 -0
  16. package/dist/backend/CrudForm.js +2 -1
  17. package/dist/backend/CrudForm.js.map +2 -2
  18. package/dist/backend/FilterOverlay.js +1 -1
  19. package/dist/backend/FilterOverlay.js.map +1 -1
  20. package/dist/backend/dashboard/widgetRegistry.js +2 -1
  21. package/dist/backend/dashboard/widgetRegistry.js.map +2 -2
  22. package/dist/backend/date-range/dateRanges.js +16 -18
  23. package/dist/backend/date-range/dateRanges.js.map +2 -2
  24. package/dist/backend/detail/AttachmentMetadataDialog.js +3 -1
  25. package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
  26. package/dist/backend/detail/InlineEditors.js +1 -1
  27. package/dist/backend/detail/InlineEditors.js.map +1 -1
  28. package/dist/backend/injection/useAppEvent.js +2 -7
  29. package/dist/backend/injection/useAppEvent.js.map +2 -2
  30. package/dist/backend/injection/widgetRegistry.js +2 -1
  31. package/dist/backend/injection/widgetRegistry.js.map +2 -2
  32. package/dist/backend/messages/useMessageCompose.js +62 -5
  33. package/dist/backend/messages/useMessageCompose.js.map +2 -2
  34. package/dist/backend/messages/useMessageComposeOperations.js +64 -5
  35. package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
  36. package/dist/backend/schedule/ScheduleCalendar.js +11 -1
  37. package/dist/backend/schedule/ScheduleCalendar.js.map +2 -2
  38. package/dist/backend/schedule/ScheduleToolbar.js +11 -1
  39. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  40. package/dist/primitives/calendar.js +1 -1
  41. package/dist/primitives/calendar.js.map +1 -1
  42. package/dist/primitives/date-format.js +1 -1
  43. package/dist/primitives/date-format.js.map +1 -1
  44. package/dist/primitives/date-picker-helpers.js +7 -9
  45. package/dist/primitives/date-picker-helpers.js.map +2 -2
  46. package/dist/primitives/date-picker.js +1 -1
  47. package/dist/primitives/date-picker.js.map +2 -2
  48. package/dist/primitives/date-range-picker.js +1 -1
  49. package/dist/primitives/date-range-picker.js.map +2 -2
  50. package/jest.config.cjs +4 -1
  51. package/package.json +6 -7
  52. package/src/ai/AiChat.tsx +138 -22
  53. package/src/ai/AiChatSessions.tsx +89 -9
  54. package/src/ai/__tests__/AiAssistantLauncher.test.tsx +1 -1
  55. package/src/ai/__tests__/AiChat.conversation.test.tsx +86 -0
  56. package/src/ai/__tests__/AiChat.test.tsx +17 -2
  57. package/src/ai/__tests__/conversation-store.test.ts +29 -0
  58. package/src/ai/conversation-store.ts +246 -0
  59. package/src/ai/parts/MutationPreviewCard.tsx +25 -20
  60. package/src/ai/parts/MutationResultCard.tsx +15 -27
  61. package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +26 -1
  62. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +16 -1
  63. package/src/ai/useAiChat.ts +97 -8
  64. package/src/backend/AuthSessionGuard.tsx +114 -0
  65. package/src/backend/CrudForm.tsx +3 -2
  66. package/src/backend/FilterOverlay.tsx +1 -1
  67. package/src/backend/__tests__/AppShell.test.tsx +1 -1
  68. package/src/backend/__tests__/AuthSessionGuard.test.tsx +150 -0
  69. package/src/backend/__tests__/SidebarCustomizationEditor.test.tsx +8 -1
  70. package/src/backend/dashboard/widgetRegistry.ts +2 -1
  71. package/src/backend/date-range/dateRanges.ts +16 -18
  72. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  73. package/src/backend/detail/InlineEditors.tsx +1 -1
  74. package/src/backend/injection/__tests__/useAppEvent.test.ts +16 -0
  75. package/src/backend/injection/useAppEvent.ts +2 -9
  76. package/src/backend/injection/widgetRegistry.ts +2 -1
  77. package/src/backend/messages/__tests__/MessageComposer.test.tsx +77 -0
  78. package/src/backend/messages/useMessageCompose.ts +64 -5
  79. package/src/backend/messages/useMessageComposeOperations.ts +70 -4
  80. package/src/backend/schedule/ScheduleCalendar.tsx +11 -1
  81. package/src/backend/schedule/ScheduleToolbar.tsx +11 -1
  82. package/src/primitives/calendar.tsx +2 -2
  83. package/src/primitives/date-format.ts +2 -2
  84. package/src/primitives/date-picker-helpers.ts +7 -9
  85. package/src/primitives/date-picker.tsx +2 -2
  86. package/src/primitives/date-range-picker.tsx +2 -2
@@ -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 {
@@ -54,5 +54,5 @@ describe('<AiAssistantLauncher>', () => {
54
54
 
55
55
  expect(await screen.findByRole('dialog', { name: 'AI assistants' })).toBeInTheDocument()
56
56
  expect(screen.getByText('Catalog Assistant')).toBeInTheDocument()
57
- })
57
+ }, 60_000)
58
58
  })
@@ -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
@@ -185,6 +185,19 @@ function createErrorResponse(status: number, payload: Record<string, unknown>):
185
185
  describe('<AiChat>', () => {
186
186
  beforeEach(() => {
187
187
  jest.clearAllMocks()
188
+ const apiCallMock = apiCall as unknown as jest.Mock
189
+ apiCallMock.mockReset()
190
+ apiCallMock.mockResolvedValue({
191
+ ok: true,
192
+ status: 200,
193
+ result: {
194
+ agentId: 'customers.account_assistant',
195
+ allowRuntimeModelOverride: false,
196
+ defaultProviderId: 'openai',
197
+ defaultModelId: 'gpt-5-mini',
198
+ providers: [],
199
+ },
200
+ })
188
201
  window.localStorage.clear()
189
202
  lastResizeObserver = null
190
203
  })
@@ -238,7 +251,9 @@ describe('<AiChat>', () => {
238
251
  'Default: openai / gpt-5-mini',
239
252
  )
240
253
  })
241
- expect(window.localStorage.getItem('om-ai-model-picker:customers.account_assistant')).toBeNull()
254
+ await waitFor(() => {
255
+ expect(window.localStorage.getItem('om-ai-model-picker:customers.account_assistant')).toBeNull()
256
+ })
242
257
  })
243
258
 
244
259
  it('does not send provider or model overrides while the model picker is on Default', async () => {
@@ -293,7 +308,7 @@ describe('<AiChat>', () => {
293
308
  expect(parsedUrl.searchParams.get('agent')).toBe('customers.deal_analyzer')
294
309
  expect(parsedUrl.searchParams.has('provider')).toBe(false)
295
310
  expect(parsedUrl.searchParams.has('model')).toBe(false)
296
- })
311
+ }, 15_000)
297
312
 
298
313
  it('renders a compact footer before a constrained host is measured', async () => {
299
314
  const apiCallMock = apiCall as unknown as jest.Mock
@@ -0,0 +1,29 @@
1
+ import { listAiServerConversations } from '../conversation-store'
2
+
3
+ describe('AI conversation store', () => {
4
+ const originalFetch = global.fetch
5
+
6
+ afterEach(() => {
7
+ global.fetch = originalFetch
8
+ jest.restoreAllMocks()
9
+ })
10
+
11
+ it('suppresses auth redirects for optional conversation sync calls', async () => {
12
+ const fetchMock = jest.fn(async () => new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }))
13
+ global.fetch = fetchMock as unknown as typeof fetch
14
+
15
+ await expect(listAiServerConversations({ limit: 100 })).resolves.toBeNull()
16
+
17
+ expect(fetchMock).toHaveBeenCalledWith(
18
+ '/api/ai_assistant/ai/conversations?limit=100',
19
+ expect.objectContaining({
20
+ credentials: 'include',
21
+ headers: expect.objectContaining({
22
+ Accept: 'application/json',
23
+ 'x-om-forbidden-redirect': '0',
24
+ 'x-om-unauthorized-redirect': '0',
25
+ }),
26
+ }),
27
+ )
28
+ })
29
+ })
@@ -0,0 +1,246 @@
1
+ "use client"
2
+
3
+ import type { AiChatMessage, AiChatMessageUiPart } from './useAiChat'
4
+
5
+ const CONVERSATIONS_ENDPOINT = '/api/ai_assistant/ai/conversations'
6
+
7
+ export interface AiServerConversation {
8
+ conversationId: string
9
+ agentId: string
10
+ title: string | null
11
+ status: 'open' | 'closed'
12
+ visibility: 'private' | 'shared' | 'organization'
13
+ pageContext: Record<string, unknown> | null
14
+ createdAt: string
15
+ updatedAt: string
16
+ lastMessageAt: string | null
17
+ importedFromLocalAt: string | null
18
+ }
19
+
20
+ export interface AiServerMessage {
21
+ id: string
22
+ clientMessageId: string | null
23
+ role: 'user' | 'assistant' | 'system'
24
+ content: string
25
+ uiParts: unknown[]
26
+ attachmentIds: string[]
27
+ files: Array<Record<string, unknown>>
28
+ model: string | null
29
+ metadata: Record<string, unknown> | null
30
+ createdAt: string
31
+ }
32
+
33
+ export interface AiServerConversationListResponse {
34
+ items: AiServerConversation[]
35
+ nextCursor: string | null
36
+ }
37
+
38
+ export interface AiServerTranscriptResponse {
39
+ conversation: AiServerConversation
40
+ messages: AiServerMessage[]
41
+ nextCursor: string | null
42
+ }
43
+
44
+ export interface AiServerImportResponse {
45
+ conversation: AiServerConversation
46
+ importedMessageCount: number
47
+ skippedMessageCount: number
48
+ }
49
+
50
+ function readString(value: unknown): string | null {
51
+ return typeof value === 'string' && value.length > 0 ? value : null
52
+ }
53
+
54
+ function buildAttachmentImagePreviewUrl(attachmentId: string): string {
55
+ const params = new URLSearchParams({
56
+ width: '320',
57
+ height: '320',
58
+ cropType: 'contain',
59
+ })
60
+ return `/api/attachments/image/${encodeURIComponent(attachmentId)}?${params.toString()}`
61
+ }
62
+
63
+ function getFetch(): typeof fetch | null {
64
+ const fetchImpl = (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch
65
+ return typeof fetchImpl === 'function' ? fetchImpl.bind(globalThis) : null
66
+ }
67
+
68
+ async function requestJson<T>(
69
+ input: string,
70
+ init?: RequestInit,
71
+ ): Promise<{ ok: true; status: number; data: T } | { ok: false; status: number; data: null }> {
72
+ const fetchImpl = getFetch()
73
+ if (!fetchImpl) return { ok: false, status: 0, data: null }
74
+ try {
75
+ const response = await fetchImpl(input, {
76
+ credentials: 'include',
77
+ ...init,
78
+ headers: {
79
+ Accept: 'application/json',
80
+ 'x-om-forbidden-redirect': '0',
81
+ 'x-om-unauthorized-redirect': '0',
82
+ ...(init?.body ? { 'Content-Type': 'application/json' } : {}),
83
+ ...(init?.headers ?? {}),
84
+ },
85
+ })
86
+ if (!response.ok) return { ok: false, status: response.status, data: null }
87
+ return { ok: true, status: response.status, data: (await response.json()) as T }
88
+ } catch {
89
+ return { ok: false, status: 0, data: null }
90
+ }
91
+ }
92
+
93
+ export async function listAiServerConversations(options: {
94
+ agentId?: string | null
95
+ status?: 'open' | 'closed' | null
96
+ limit?: number
97
+ } = {}): Promise<AiServerConversation[] | null> {
98
+ const params = new URLSearchParams()
99
+ if (options.agentId) params.set('agent', options.agentId)
100
+ if (options.status) params.set('status', options.status)
101
+ params.set('limit', String(options.limit ?? 100))
102
+ const result = await requestJson<AiServerConversationListResponse>(
103
+ `${CONVERSATIONS_ENDPOINT}?${params.toString()}`,
104
+ )
105
+ return result.ok ? result.data.items : null
106
+ }
107
+
108
+ export async function createAiServerConversation(input: {
109
+ agentId: string
110
+ conversationId: string
111
+ title?: string | null
112
+ pageContext?: Record<string, unknown> | null
113
+ }): Promise<AiServerConversation | null> {
114
+ const result = await requestJson<AiServerConversation>(CONVERSATIONS_ENDPOINT, {
115
+ method: 'POST',
116
+ body: JSON.stringify({
117
+ agentId: input.agentId,
118
+ conversationId: input.conversationId,
119
+ title: input.title ?? undefined,
120
+ pageContext: input.pageContext ?? undefined,
121
+ }),
122
+ })
123
+ return result.ok ? result.data : null
124
+ }
125
+
126
+ export async function updateAiServerConversation(
127
+ conversationId: string,
128
+ patch: {
129
+ title?: string | null
130
+ status?: 'open' | 'closed'
131
+ pageContext?: Record<string, unknown> | null
132
+ },
133
+ ): Promise<AiServerConversation | null> {
134
+ const result = await requestJson<AiServerConversation>(
135
+ `${CONVERSATIONS_ENDPOINT}/${encodeURIComponent(conversationId)}`,
136
+ {
137
+ method: 'PATCH',
138
+ body: JSON.stringify(patch),
139
+ },
140
+ )
141
+ return result.ok ? result.data : null
142
+ }
143
+
144
+ export async function loadAiServerTranscript(
145
+ conversationId: string,
146
+ options: { limit?: number } = {},
147
+ ): Promise<AiServerTranscriptResponse | null> {
148
+ const params = new URLSearchParams()
149
+ params.set('limit', String(options.limit ?? 100))
150
+ const result = await requestJson<AiServerTranscriptResponse>(
151
+ `${CONVERSATIONS_ENDPOINT}/${encodeURIComponent(conversationId)}?${params.toString()}`,
152
+ )
153
+ return result.ok ? result.data : null
154
+ }
155
+
156
+ export async function importAiLocalConversation(input: {
157
+ agentId: string
158
+ conversationId: string
159
+ title?: string | null
160
+ status?: 'open' | 'closed'
161
+ pageContext?: Record<string, unknown> | null
162
+ messages: AiChatMessage[]
163
+ }): Promise<AiServerImportResponse | null> {
164
+ const result = await requestJson<AiServerImportResponse>(`${CONVERSATIONS_ENDPOINT}/import`, {
165
+ method: 'POST',
166
+ body: JSON.stringify({
167
+ conversation: {
168
+ conversationId: input.conversationId,
169
+ agentId: input.agentId,
170
+ title: input.title ?? undefined,
171
+ status: input.status ?? 'open',
172
+ pageContext: input.pageContext ?? undefined,
173
+ },
174
+ messages: input.messages.slice(-100).map((message) => ({
175
+ clientMessageId: message.id,
176
+ role: message.role,
177
+ content: message.content,
178
+ uiParts: message.uiParts ?? [],
179
+ files: (message.files ?? []).map((file) => ({
180
+ id: file.id,
181
+ name: file.name,
182
+ mimeType: file.type,
183
+ })),
184
+ })),
185
+ }),
186
+ })
187
+ return result.ok ? result.data : null
188
+ }
189
+
190
+ export function serverMessageToChatMessage(message: AiServerMessage): AiChatMessage | null {
191
+ if (message.role !== 'user' && message.role !== 'assistant') return null
192
+ const uiParts: AiChatMessageUiPart[] = []
193
+ message.uiParts.forEach((part, index) => {
194
+ if (!part || typeof part !== 'object') return
195
+ const value = part as Record<string, unknown>
196
+ const componentId =
197
+ typeof value.componentId === 'string'
198
+ ? value.componentId
199
+ : typeof value.type === 'string'
200
+ ? value.type
201
+ : null
202
+ if (!componentId) return
203
+ uiParts.push({
204
+ componentId,
205
+ payload: value.payload ?? value.props,
206
+ pendingActionId:
207
+ typeof value.pendingActionId === 'string' ? value.pendingActionId : undefined,
208
+ key:
209
+ typeof value.key === 'string'
210
+ ? value.key
211
+ : `${message.id}:${index}:${componentId}`,
212
+ })
213
+ })
214
+ return {
215
+ id: message.clientMessageId ?? message.id,
216
+ role: message.role,
217
+ content: message.content,
218
+ files: message.files
219
+ .map((file, index) => {
220
+ const name = readString(file.name)
221
+ if (!name) return null
222
+ const type =
223
+ typeof file.mimeType === 'string'
224
+ ? file.mimeType
225
+ : typeof file.type === 'string'
226
+ ? file.type
227
+ : 'application/octet-stream'
228
+ const id = readString(file.id) ?? readString(message.attachmentIds[index])
229
+ const rawPreviewUrl =
230
+ readString(file.previewUrl) ??
231
+ readString(file.thumbnailUrl) ??
232
+ readString(file.url)
233
+ const previewUrl = type.startsWith('image/')
234
+ ? rawPreviewUrl ?? (id ? buildAttachmentImagePreviewUrl(id) : undefined)
235
+ : undefined
236
+ return {
237
+ ...(id ? { id } : {}),
238
+ name,
239
+ type,
240
+ ...(previewUrl ? { previewUrl } : {}),
241
+ }
242
+ })
243
+ .filter((file): file is NonNullable<AiChatMessage['files']>[number] => file !== null),
244
+ uiParts,
245
+ }
246
+ }
@@ -180,6 +180,7 @@ export function MutationPreviewCard(props: MutationPreviewCardProps) {
180
180
 
181
181
  const batch = Array.isArray(action?.records) && action!.records!.length > 0 ? action!.records! : null
182
182
  const summary = batch ? summarizeBatch(batch) : null
183
+ const hasReviewDetails = Boolean(batch)
183
184
 
184
185
  return (
185
186
  <section
@@ -242,7 +243,7 @@ export function MutationPreviewCard(props: MutationPreviewCardProps) {
242
243
  )}
243
244
  </div>
244
245
 
245
- {expanded ? (
246
+ {expanded && hasReviewDetails ? (
246
247
  <div className="mt-3 rounded-md border border-border bg-muted/20 p-3" data-ai-mutation-preview-details>
247
248
  <FieldDiffCard
248
249
  fieldDiff={action?.fieldDiff ?? null}
@@ -252,25 +253,29 @@ export function MutationPreviewCard(props: MutationPreviewCardProps) {
252
253
  ) : null}
253
254
 
254
255
  <div className="mt-3 flex items-center justify-between gap-2">
255
- <Button
256
- type="button"
257
- variant="ghost"
258
- size="sm"
259
- onClick={() => setExpanded((value) => !value)}
260
- data-ai-mutation-preview-review
261
- >
262
- <Eye className="size-4" aria-hidden />
263
- <span>
264
- {t(
265
- 'ai_assistant.chat.mutation_cards.preview.reviewDetails',
266
- 'Review details',
267
- )}
268
- </span>
269
- <ChevronDown
270
- className={`size-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
271
- aria-hidden
272
- />
273
- </Button>
256
+ {hasReviewDetails ? (
257
+ <Button
258
+ type="button"
259
+ variant="ghost"
260
+ size="sm"
261
+ onClick={() => setExpanded((value) => !value)}
262
+ data-ai-mutation-preview-review
263
+ >
264
+ <Eye className="size-4" aria-hidden />
265
+ <span>
266
+ {t(
267
+ 'ai_assistant.chat.mutation_cards.preview.reviewDetails',
268
+ 'Review details',
269
+ )}
270
+ </span>
271
+ <ChevronDown
272
+ className={`size-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
273
+ aria-hidden
274
+ />
275
+ </Button>
276
+ ) : (
277
+ <span aria-hidden />
278
+ )}
274
279
  <div className="flex items-center gap-2">
275
280
  <Button
276
281
  type="button"