@open-mercato/ui 0.6.3-develop.3901.1.ddad60693a → 0.6.3

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 (67) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/ai/AiAssistantLauncher.js +61 -8
  3. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  4. package/dist/ai/AiChat.js +29 -6
  5. package/dist/ai/AiChat.js.map +2 -2
  6. package/dist/ai/AiChatSessions.js +31 -9
  7. package/dist/ai/AiChatSessions.js.map +2 -2
  8. package/dist/ai/AiDock.js +8 -1
  9. package/dist/ai/AiDock.js.map +2 -2
  10. package/dist/ai/ConversationShareButton.js +40 -0
  11. package/dist/ai/ConversationShareButton.js.map +7 -0
  12. package/dist/ai/ConversationShareDialog.js +234 -0
  13. package/dist/ai/ConversationShareDialog.js.map +7 -0
  14. package/dist/ai/conversation-store.js +4 -2
  15. package/dist/ai/conversation-store.js.map +2 -2
  16. package/dist/ai/index.js +4 -0
  17. package/dist/ai/index.js.map +2 -2
  18. package/dist/ai/useAiChat.js +20 -4
  19. package/dist/ai/useAiChat.js.map +2 -2
  20. package/dist/backend/charts/BarChart.js +19 -76
  21. package/dist/backend/charts/BarChart.js.map +2 -2
  22. package/dist/backend/charts/BarChartImpl.js +91 -0
  23. package/dist/backend/charts/BarChartImpl.js.map +7 -0
  24. package/dist/backend/charts/LineChart.js +18 -91
  25. package/dist/backend/charts/LineChart.js.map +2 -2
  26. package/dist/backend/charts/LineChartImpl.js +112 -0
  27. package/dist/backend/charts/LineChartImpl.js.map +7 -0
  28. package/dist/backend/charts/PieChart.js +17 -62
  29. package/dist/backend/charts/PieChart.js.map +2 -2
  30. package/dist/backend/charts/PieChartImpl.js +76 -0
  31. package/dist/backend/charts/PieChartImpl.js.map +7 -0
  32. package/dist/backend/dashboard/DashboardScreen.js +9 -1
  33. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  34. package/dist/backend/dashboard/widgetRegistry.js +18 -2
  35. package/dist/backend/dashboard/widgetRegistry.js.map +2 -2
  36. package/dist/backend/icons/lucideRegistry.generated.js +2 -0
  37. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  38. package/dist/backend/injection/useInjectionDataWidgets.js +12 -2
  39. package/dist/backend/injection/useInjectionDataWidgets.js.map +2 -2
  40. package/package.json +4 -5
  41. package/src/ai/AiAssistantLauncher.tsx +81 -5
  42. package/src/ai/AiChat.tsx +42 -2
  43. package/src/ai/AiChatSessions.tsx +55 -9
  44. package/src/ai/AiDock.tsx +13 -0
  45. package/src/ai/ConversationShareButton.tsx +39 -0
  46. package/src/ai/ConversationShareDialog.tsx +299 -0
  47. package/src/ai/__tests__/AiAssistantLauncher.test.tsx +6 -0
  48. package/src/ai/__tests__/AiChat.conversation.test.tsx +97 -0
  49. package/src/ai/__tests__/AiChatSessions.test.tsx +228 -0
  50. package/src/ai/__tests__/AiDock.test.tsx +31 -1
  51. package/src/ai/__tests__/conversation-store.test.ts +74 -1
  52. package/src/ai/conversation-store.ts +22 -2
  53. package/src/ai/index.ts +2 -0
  54. package/src/ai/useAiChat.ts +47 -4
  55. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +53 -0
  56. package/src/backend/charts/BarChart.tsx +21 -72
  57. package/src/backend/charts/BarChartImpl.tsx +99 -0
  58. package/src/backend/charts/LineChart.tsx +23 -85
  59. package/src/backend/charts/LineChartImpl.tsx +120 -0
  60. package/src/backend/charts/PieChart.tsx +19 -60
  61. package/src/backend/charts/PieChartImpl.tsx +82 -0
  62. package/src/backend/dashboard/DashboardScreen.tsx +9 -1
  63. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +45 -0
  64. package/src/backend/dashboard/__tests__/widgetRegistry.test.ts +60 -0
  65. package/src/backend/dashboard/widgetRegistry.ts +23 -5
  66. package/src/backend/icons/lucideRegistry.generated.tsx +2 -0
  67. package/src/backend/injection/useInjectionDataWidgets.ts +18 -2
@@ -158,6 +158,103 @@ describe('<AiChat> conversationId threading (Step 5.15)', () => {
158
158
  expect(parsedBody.conversationId).toBe('conv-body-xyz')
159
159
  })
160
160
 
161
+ it('invokes onConversationNotFound and does not import local messages when server returns 404', async () => {
162
+ const originalFetch = globalThis.fetch
163
+ const fetchMock = jest.fn((input: string | URL | Request) => {
164
+ const url = typeof input === 'string' ? input : input.toString()
165
+ if (url.includes('/api/ai_assistant/ai/conversations/conv-stale')) {
166
+ return Promise.resolve({
167
+ ok: false,
168
+ status: 404,
169
+ json: async () => ({ error: 'not found' }),
170
+ } as unknown as Response)
171
+ }
172
+ // Any /import call would be a regression we must catch:
173
+ if (url.includes('/import')) {
174
+ return Promise.resolve({
175
+ ok: true,
176
+ status: 200,
177
+ json: async () => ({}),
178
+ } as unknown as Response)
179
+ }
180
+ return Promise.resolve({
181
+ ok: true,
182
+ status: 200,
183
+ json: async () => ({}),
184
+ } as unknown as Response)
185
+ }) as unknown as typeof fetch
186
+ globalThis.fetch = fetchMock
187
+
188
+ const onConversationNotFound = jest.fn()
189
+
190
+ try {
191
+ renderWithProviders(
192
+ <AiChat
193
+ agent="customers.account_assistant"
194
+ conversationId="conv-stale"
195
+ onConversationNotFound={onConversationNotFound}
196
+ />,
197
+ { dict },
198
+ )
199
+
200
+ // hydrate effect is async — wait one tick for the 404 to land
201
+ await act(async () => {
202
+ await new Promise((resolve) => setTimeout(resolve, 0))
203
+ })
204
+
205
+ expect(onConversationNotFound).toHaveBeenCalledTimes(1)
206
+ // No /import call must happen on 404 — cross-tenant data write guard.
207
+ const importCalls = fetchMock.mock.calls.filter(([url]) => {
208
+ const u = typeof url === 'string' ? url : (url as { toString(): string }).toString()
209
+ return u.includes('/conversations/import')
210
+ })
211
+ expect(importCalls).toHaveLength(0)
212
+ } finally {
213
+ globalThis.fetch = originalFetch
214
+ }
215
+ })
216
+
217
+ it('does NOT invoke onConversationNotFound on transient transport failure (503)', async () => {
218
+ const originalFetch = globalThis.fetch
219
+ const fetchMock = jest.fn((input: string | URL | Request) => {
220
+ const url = typeof input === 'string' ? input : input.toString()
221
+ if (url.includes('/api/ai_assistant/ai/conversations/conv-503')) {
222
+ return Promise.resolve({
223
+ ok: false,
224
+ status: 503,
225
+ json: async () => ({ error: 'upstream' }),
226
+ } as unknown as Response)
227
+ }
228
+ return Promise.resolve({
229
+ ok: true,
230
+ status: 200,
231
+ json: async () => ({}),
232
+ } as unknown as Response)
233
+ }) as unknown as typeof fetch
234
+ globalThis.fetch = fetchMock
235
+
236
+ const onConversationNotFound = jest.fn()
237
+
238
+ try {
239
+ renderWithProviders(
240
+ <AiChat
241
+ agent="customers.account_assistant"
242
+ conversationId="conv-503"
243
+ onConversationNotFound={onConversationNotFound}
244
+ />,
245
+ { dict },
246
+ )
247
+
248
+ await act(async () => {
249
+ await new Promise((resolve) => setTimeout(resolve, 0))
250
+ })
251
+
252
+ expect(onConversationNotFound).not.toHaveBeenCalled()
253
+ } finally {
254
+ globalThis.fetch = originalFetch
255
+ }
256
+ })
257
+
161
258
  it('hydrates the transcript from server storage for an existing conversation', async () => {
162
259
  const originalFetch = globalThis.fetch
163
260
  const fetchMock = jest.fn(async () => ({
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { act, screen, waitFor } from '@testing-library/react'
7
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
8
+ import {
9
+ emitOrganizationScopeChanged,
10
+ } from '@open-mercato/shared/lib/frontend/organizationEvents'
11
+ import {
12
+ AiChatSessionsProvider,
13
+ LEGACY_STORAGE_KEY,
14
+ useAiChatSessions,
15
+ } from '../AiChatSessions'
16
+
17
+ jest.mock('../conversation-store', () => {
18
+ const actual = jest.requireActual('../conversation-store') as Record<string, unknown>
19
+ return {
20
+ ...actual,
21
+ listAiServerConversations: jest.fn(async () => null),
22
+ createAiServerConversation: jest.fn(async () => null),
23
+ updateAiServerConversation: jest.fn(async () => null),
24
+ }
25
+ })
26
+
27
+ import {
28
+ listAiServerConversations,
29
+ createAiServerConversation,
30
+ } from '../conversation-store'
31
+
32
+ const listMock = listAiServerConversations as jest.MockedFunction<typeof listAiServerConversations>
33
+ const createMock = createAiServerConversation as jest.MockedFunction<typeof createAiServerConversation>
34
+
35
+ const KEY_PREFIX = 'om-ai-chat-sessions-v1'
36
+
37
+ function scopedKey(tenantId: string | null, organizationId: string | null): string {
38
+ return `${KEY_PREFIX}:${tenantId ?? 'no-tenant'}:${organizationId ?? 'no-org'}`
39
+ }
40
+
41
+ function CreateSessionButton({ agentId }: { agentId: string }) {
42
+ const sessions = useAiChatSessions()
43
+ return (
44
+ <button type="button" onClick={() => sessions.createSession(agentId)}>
45
+ Create session
46
+ </button>
47
+ )
48
+ }
49
+
50
+ function SessionCount({ agentId }: { agentId: string }) {
51
+ const sessions = useAiChatSessions()
52
+ const open = sessions.getOpenSessions(agentId)
53
+ return <span data-testid="session-count">{open.length}</span>
54
+ }
55
+
56
+ function Harness({ agentId }: { agentId: string }) {
57
+ return (
58
+ <AiChatSessionsProvider>
59
+ <CreateSessionButton agentId={agentId} />
60
+ <SessionCount agentId={agentId} />
61
+ </AiChatSessionsProvider>
62
+ )
63
+ }
64
+
65
+ describe('<AiChatSessionsProvider> — tenant/org scope isolation', () => {
66
+ beforeEach(() => {
67
+ window.localStorage.clear()
68
+ listMock.mockReset()
69
+ listMock.mockResolvedValue(null)
70
+ createMock.mockReset()
71
+ createMock.mockResolvedValue(null)
72
+ // Reset scope to a known starting point. The module-level state in
73
+ // organizationEvents persists across tests in the same file.
74
+ act(() => {
75
+ emitOrganizationScopeChanged({ tenantId: 'T1', organizationId: 'O1' })
76
+ })
77
+ })
78
+
79
+ afterEach(() => {
80
+ window.localStorage.clear()
81
+ })
82
+
83
+ it('does not read or write the legacy unscoped key', async () => {
84
+ window.localStorage.setItem(
85
+ LEGACY_STORAGE_KEY,
86
+ JSON.stringify({
87
+ sessions: [
88
+ {
89
+ id: 'legacy-session',
90
+ agentId: 'assistant',
91
+ conversationId: 'legacy-conv',
92
+ createdAt: 1,
93
+ lastUsedAt: 1,
94
+ status: 'open',
95
+ },
96
+ ],
97
+ activeByAgent: { assistant: 'legacy-session' },
98
+ }),
99
+ )
100
+
101
+ renderWithProviders(<Harness agentId="assistant" />)
102
+
103
+ await waitFor(() => {
104
+ expect(screen.getByTestId('session-count').textContent).toBe('0')
105
+ })
106
+
107
+ expect(window.localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull()
108
+ })
109
+
110
+ it('writes new sessions to the tenant/org scoped key', async () => {
111
+ renderWithProviders(<Harness agentId="assistant" />)
112
+
113
+ act(() => {
114
+ screen.getByRole('button', { name: 'Create session' }).click()
115
+ })
116
+
117
+ await waitFor(() => {
118
+ expect(screen.getByTestId('session-count').textContent).toBe('1')
119
+ })
120
+
121
+ const scoped = window.localStorage.getItem(scopedKey('T1', 'O1'))
122
+ expect(scoped).not.toBeNull()
123
+ const parsed = JSON.parse(scoped!) as { sessions: Array<{ agentId: string }> }
124
+ expect(parsed.sessions).toHaveLength(1)
125
+ expect(parsed.sessions[0].agentId).toBe('assistant')
126
+
127
+ expect(window.localStorage.getItem(scopedKey('T2', 'O2'))).toBeNull()
128
+ })
129
+
130
+ it('rehydrates from the new scope on scope change (no flash of previous-scope data)', async () => {
131
+ // Seed BOTH scopes' buckets with distinct session counts so we can
132
+ // observe the swap.
133
+ window.localStorage.setItem(
134
+ scopedKey('T1', 'O1'),
135
+ JSON.stringify({
136
+ sessions: [
137
+ {
138
+ id: 's1',
139
+ agentId: 'assistant',
140
+ conversationId: 'c1',
141
+ createdAt: 1,
142
+ lastUsedAt: 1,
143
+ status: 'open',
144
+ },
145
+ {
146
+ id: 's2',
147
+ agentId: 'assistant',
148
+ conversationId: 'c2',
149
+ createdAt: 2,
150
+ lastUsedAt: 2,
151
+ status: 'open',
152
+ },
153
+ ],
154
+ activeByAgent: { assistant: 's1' },
155
+ }),
156
+ )
157
+ window.localStorage.setItem(
158
+ scopedKey('T2', 'O2'),
159
+ JSON.stringify({
160
+ sessions: [
161
+ {
162
+ id: 's3',
163
+ agentId: 'assistant',
164
+ conversationId: 'c3',
165
+ createdAt: 3,
166
+ lastUsedAt: 3,
167
+ status: 'open',
168
+ },
169
+ ],
170
+ activeByAgent: { assistant: 's3' },
171
+ }),
172
+ )
173
+
174
+ renderWithProviders(<Harness agentId="assistant" />)
175
+
176
+ await waitFor(() => {
177
+ expect(screen.getByTestId('session-count').textContent).toBe('2')
178
+ })
179
+
180
+ act(() => {
181
+ emitOrganizationScopeChanged({ tenantId: 'T2', organizationId: 'O2' })
182
+ })
183
+
184
+ await waitFor(() => {
185
+ expect(screen.getByTestId('session-count').textContent).toBe('1')
186
+ })
187
+
188
+ // T1/O1 bucket must remain untouched after the swap.
189
+ const t1Raw = window.localStorage.getItem(scopedKey('T1', 'O1'))
190
+ expect(t1Raw).not.toBeNull()
191
+ const t1Parsed = JSON.parse(t1Raw!) as { sessions: unknown[] }
192
+ expect(t1Parsed.sessions).toHaveLength(2)
193
+ })
194
+
195
+ it('refires the server conversation list on scope change', async () => {
196
+ renderWithProviders(<Harness agentId="assistant" />)
197
+
198
+ await waitFor(() => {
199
+ expect(listMock).toHaveBeenCalledTimes(1)
200
+ })
201
+
202
+ act(() => {
203
+ emitOrganizationScopeChanged({ tenantId: 'T2', organizationId: 'O2' })
204
+ })
205
+
206
+ await waitFor(() => {
207
+ expect(listMock).toHaveBeenCalledTimes(2)
208
+ })
209
+ })
210
+
211
+ it('falls back to the no-tenant/no-org bucket when scope is unresolved', async () => {
212
+ act(() => {
213
+ emitOrganizationScopeChanged({ tenantId: null, organizationId: null })
214
+ })
215
+
216
+ renderWithProviders(<Harness agentId="assistant" />)
217
+
218
+ act(() => {
219
+ screen.getByRole('button', { name: 'Create session' }).click()
220
+ })
221
+
222
+ await waitFor(() => {
223
+ expect(screen.getByTestId('session-count').textContent).toBe('1')
224
+ })
225
+
226
+ expect(window.localStorage.getItem(scopedKey(null, null))).not.toBeNull()
227
+ })
228
+ })
@@ -11,11 +11,21 @@ import { AiDockProvider, useAiDock, type AiDockedAssistant } from '../AiDock'
11
11
  jest.mock('../AiChat', () => {
12
12
  const ReactModule = require('react') as typeof import('react')
13
13
  return {
14
- AiChat: ({ agent, defaultCompactFooter }: { agent: string; defaultCompactFooter?: boolean }) =>
14
+ AiChat: ({
15
+ agent,
16
+ defaultCompactFooter,
17
+ onConversationNotFound,
18
+ }: {
19
+ agent: string
20
+ defaultCompactFooter?: boolean
21
+ onConversationNotFound?: () => void
22
+ }) =>
15
23
  ReactModule.createElement('div', {
16
24
  'data-testid': 'ai-chat',
17
25
  'data-agent': agent,
18
26
  'data-default-compact-footer': defaultCompactFooter ? 'true' : 'false',
27
+ 'data-has-conversation-not-found-handler': typeof onConversationNotFound === 'function' ? 'true' : 'false',
28
+ onClick: () => onConversationNotFound?.(),
19
29
  }),
20
30
  }
21
31
  })
@@ -132,4 +142,24 @@ describe('<AiDockProvider>', () => {
132
142
  expect(screen.getByTestId('ai-chat')).toHaveAttribute('data-default-compact-footer', 'true')
133
143
  })
134
144
  })
145
+
146
+ it('wires onConversationNotFound so a stale session closes (no perpetual blank chat)', async () => {
147
+ renderWithProviders(<Harness />)
148
+
149
+ fireEvent.click(screen.getByRole('button', { name: 'Dock assistant' }))
150
+
151
+ const chat = await waitFor(() => screen.getByTestId('ai-chat'))
152
+ expect(chat).toHaveAttribute('data-has-conversation-not-found-handler', 'true')
153
+
154
+ // Fire the not-found callback via the mocked AiChat's onClick.
155
+ fireEvent.click(chat)
156
+
157
+ // After closeSession, the active session disappears; DockedChatBody
158
+ // schedules ensureSession in an effect, so for one tick the AiChat
159
+ // surface should be torn down before the new session mounts.
160
+ await waitFor(() => {
161
+ const next = screen.queryByTestId('ai-chat')
162
+ if (next === chat) throw new Error('stale chat surface should have unmounted')
163
+ })
164
+ })
135
165
  })
@@ -1,4 +1,4 @@
1
- import { listAiServerConversations } from '../conversation-store'
1
+ import { listAiServerConversations, loadAiServerTranscript } from '../conversation-store'
2
2
 
3
3
  describe('AI conversation store', () => {
4
4
  const originalFetch = global.fetch
@@ -26,4 +26,77 @@ describe('AI conversation store', () => {
26
26
  }),
27
27
  )
28
28
  })
29
+
30
+ describe('loadAiServerTranscript', () => {
31
+ it('returns ok=true with parsed data on 200', async () => {
32
+ const payload = {
33
+ conversation: {
34
+ conversationId: 'conv-1',
35
+ agentId: 'agent-1',
36
+ title: null,
37
+ status: 'open',
38
+ visibility: 'private',
39
+ pageContext: null,
40
+ createdAt: '2026-05-28T00:00:00Z',
41
+ updatedAt: '2026-05-28T00:00:00Z',
42
+ lastMessageAt: null,
43
+ importedFromLocalAt: null,
44
+ isOwner: true,
45
+ },
46
+ messages: [],
47
+ nextCursor: null,
48
+ }
49
+ global.fetch = jest.fn(async () => new Response(JSON.stringify(payload), { status: 200 })) as unknown as typeof fetch
50
+
51
+ const result = await loadAiServerTranscript('conv-1')
52
+
53
+ expect(result).toEqual({ ok: true, data: payload })
54
+ })
55
+
56
+ it('returns ok=false notFound=true on 404 (stale conversationId from previous scope)', async () => {
57
+ global.fetch = jest.fn(async () => new Response('{"error":"not found"}', { status: 404 })) as unknown as typeof fetch
58
+
59
+ const result = await loadAiServerTranscript('conv-from-old-scope')
60
+
61
+ expect(result).toEqual({ ok: false, notFound: true })
62
+ })
63
+
64
+ it('returns ok=false notFound=false on 503 (transient transport failure)', async () => {
65
+ global.fetch = jest.fn(async () => new Response('{"error":"upstream"}', { status: 503 })) as unknown as typeof fetch
66
+
67
+ const result = await loadAiServerTranscript('conv-1')
68
+
69
+ expect(result).toEqual({ ok: false, notFound: false })
70
+ })
71
+
72
+ it('returns ok=false notFound=false on 403 (no scope membership)', async () => {
73
+ global.fetch = jest.fn(async () => new Response('{"error":"forbidden"}', { status: 403 })) as unknown as typeof fetch
74
+
75
+ const result = await loadAiServerTranscript('conv-1')
76
+
77
+ expect(result).toEqual({ ok: false, notFound: false })
78
+ })
79
+
80
+ it('returns ok=false notFound=false when fetch throws', async () => {
81
+ global.fetch = jest.fn(async () => {
82
+ throw new TypeError('Failed to fetch')
83
+ }) as unknown as typeof fetch
84
+
85
+ const result = await loadAiServerTranscript('conv-1')
86
+
87
+ expect(result).toEqual({ ok: false, notFound: false })
88
+ })
89
+
90
+ it('forwards the limit option as a query string', async () => {
91
+ const fetchMock = jest.fn(async () => new Response('{}', { status: 404 }))
92
+ global.fetch = fetchMock as unknown as typeof fetch
93
+
94
+ await loadAiServerTranscript('conv-1', { limit: 25 })
95
+
96
+ expect(fetchMock).toHaveBeenCalledWith(
97
+ '/api/ai_assistant/ai/conversations/conv-1?limit=25',
98
+ expect.any(Object),
99
+ )
100
+ })
101
+ })
29
102
  })
@@ -15,6 +15,8 @@ export interface AiServerConversation {
15
15
  updatedAt: string
16
16
  lastMessageAt: string | null
17
17
  importedFromLocalAt: string | null
18
+ /** `true` when the authenticated caller created the conversation; `false` for shared participants; `null` when unknown. */
19
+ isOwner: boolean | null
18
20
  }
19
21
 
20
22
  export interface AiServerMessage {
@@ -28,6 +30,7 @@ export interface AiServerMessage {
28
30
  model: string | null
29
31
  metadata: Record<string, unknown> | null
30
32
  createdAt: string
33
+ senderUserId: string | null
31
34
  }
32
35
 
33
36
  export interface AiServerConversationListResponse {
@@ -47,6 +50,21 @@ export interface AiServerImportResponse {
47
50
  skippedMessageCount: number
48
51
  }
49
52
 
53
+ /**
54
+ * Discriminated result of {@link loadAiServerTranscript}. Callers must
55
+ * distinguish 404 (the conversation does not exist for the current scope —
56
+ * e.g. it belonged to a previous tenant/org) from other failures (network
57
+ * down, server error) because the recovery strategies are opposite:
58
+ * - `notFound: true` → drop the local cache, optionally surface a
59
+ * not-found callback. NEVER import local messages
60
+ * onto the new scope.
61
+ * - `notFound: false` → preserve local state and retry later.
62
+ */
63
+ export type LoadTranscriptResult =
64
+ | { ok: true; data: AiServerTranscriptResponse }
65
+ | { ok: false; notFound: true }
66
+ | { ok: false; notFound: false }
67
+
50
68
  function readString(value: unknown): string | null {
51
69
  return typeof value === 'string' && value.length > 0 ? value : null
52
70
  }
@@ -144,13 +162,14 @@ export async function updateAiServerConversation(
144
162
  export async function loadAiServerTranscript(
145
163
  conversationId: string,
146
164
  options: { limit?: number } = {},
147
- ): Promise<AiServerTranscriptResponse | null> {
165
+ ): Promise<LoadTranscriptResult> {
148
166
  const params = new URLSearchParams()
149
167
  params.set('limit', String(options.limit ?? 100))
150
168
  const result = await requestJson<AiServerTranscriptResponse>(
151
169
  `${CONVERSATIONS_ENDPOINT}/${encodeURIComponent(conversationId)}?${params.toString()}`,
152
170
  )
153
- return result.ok ? result.data : null
171
+ if (result.ok) return { ok: true, data: result.data }
172
+ return { ok: false, notFound: result.status === 404 }
154
173
  }
155
174
 
156
175
  export async function importAiLocalConversation(input: {
@@ -242,5 +261,6 @@ export function serverMessageToChatMessage(message: AiServerMessage): AiChatMess
242
261
  })
243
262
  .filter((file): file is NonNullable<AiChatMessage['files']>[number] => file !== null),
244
263
  uiParts,
264
+ senderUserId: message.senderUserId ?? null,
245
265
  }
246
266
  }
package/src/ai/index.ts CHANGED
@@ -126,6 +126,8 @@ export {
126
126
  type AiChatUploadFileStatus,
127
127
  } from './useAiChatUpload'
128
128
  export { LoopDisabledBanner } from './LoopDisabledBanner'
129
+ export { ConversationShareButton } from './ConversationShareButton'
130
+ export { ConversationShareDialog } from './ConversationShareDialog'
129
131
  export {
130
132
  LoopTracePanel,
131
133
  type LoopTracePanelProps,
@@ -69,6 +69,8 @@ export interface AiChatMessage {
69
69
  reasoning?: string
70
70
  reasoningStreaming?: boolean
71
71
  toolCalls?: AiChatToolCallSnapshot[]
72
+ /** User ID of the message author. Present for server-loaded messages; null for AI messages and locally-composed messages. */
73
+ senderUserId?: string | null
72
74
  /**
73
75
  * UI parts emitted by the agent during this message's lifecycle. Today
74
76
  * the only producer is `prepareMutation` (mutation approval flow):
@@ -116,6 +118,15 @@ export interface UseAiChatInput {
116
118
  * configured default (no override sent).
117
119
  */
118
120
  modelOverride?: string | null
121
+ /**
122
+ * Called when the server returns 404 for the active `conversationId`.
123
+ * Used by hosts to remove a stale session entry (e.g. the AI dock
124
+ * closes the session tab) when a conversation no longer exists for the
125
+ * current tenant/org scope. A 404 here distinguishes a missing
126
+ * conversation from a transient transport failure — the hook never
127
+ * imports local messages onto the new scope when this fires.
128
+ */
129
+ onConversationNotFound?: () => void
119
130
  }
120
131
 
121
132
  export interface AiChatErrorEnvelope {
@@ -145,6 +156,11 @@ export interface UseAiChatResult {
145
156
  * Phase 4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
146
157
  */
147
158
  lastLoopTrace: LoopTracePanelTrace | null
159
+ /**
160
+ * Whether the authenticated caller owns the loaded conversation. `true` for owners,
161
+ * `false` for shared participants (read-only view), `null` while unresolved.
162
+ */
163
+ isOwner: boolean | null
148
164
  sendMessage: (input: string, files?: AiChatMessageFile[]) => Promise<void>
149
165
  cancel: () => void
150
166
  reset: () => void
@@ -772,7 +788,7 @@ async function readErrorEnvelope(response: Response): Promise<AiChatErrorEnvelop
772
788
  }
773
789
 
774
790
  export function useAiChat(input: UseAiChatInput): UseAiChatResult {
775
- const { agent, apiPath, pageContext, attachmentIds, debug, initialMessages, onError, conversationId: conversationIdInput, providerOverride, modelOverride } = input
791
+ const { agent, apiPath, pageContext, attachmentIds, debug, initialMessages, onError, conversationId: conversationIdInput, providerOverride, modelOverride, onConversationNotFound } = input
776
792
 
777
793
  // Minted once on mount when the caller does not supply a conversationId.
778
794
  // The ref keeps the id stable across re-renders and is reused for every
@@ -918,10 +934,14 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
918
934
  const localCandidate = readPersistedSession(agent, persistKey)
919
935
 
920
936
  async function hydrateFromServer(): Promise<void> {
921
- const transcript = await loadAiServerTranscript(effectiveConversationId, { limit: 100 })
937
+ const transcriptResult = await loadAiServerTranscript(effectiveConversationId, { limit: 100 })
922
938
  if (cancelled || latestStatusRef.current !== 'idle') return
923
939
 
924
- if (transcript) {
940
+ if (transcriptResult.ok) {
941
+ const transcript = transcriptResult.data
942
+ if (typeof transcript.conversation.isOwner === 'boolean') {
943
+ setIsOwner(transcript.conversation.isOwner)
944
+ }
925
945
  const serverMessages = transcript.messages
926
946
  .map(serverMessageToChatMessage)
927
947
  .filter((message): message is AiChatMessage => message !== null)
@@ -932,8 +952,25 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
932
952
  }
933
953
  }
934
954
 
955
+ // 404 — the conversation does not exist for the current tenant/org
956
+ // scope (e.g. the user switched scope and the stale conversationId
957
+ // came from the previous bucket's localStorage). Self-heal: drop
958
+ // the local cache, signal the host so it can prune its session
959
+ // registry, and NEVER fall through to importAiLocalConversation —
960
+ // that would silently write the previous scope's messages onto the
961
+ // new scope's server.
962
+ if (!transcriptResult.ok && transcriptResult.notFound) {
963
+ updateMessages([])
964
+ clearPersistedSession(agent, persistKey)
965
+ onConversationNotFoundRef.current?.()
966
+ return
967
+ }
968
+
935
969
  if (!localCandidate || localCandidate.messages.length === 0) {
936
- if (!transcript) {
970
+ if (!transcriptResult.ok) {
971
+ // Transient transport failure (network down, 5xx, …). Mint a
972
+ // new server-side record optimistically; the next idle hydrate
973
+ // will reconcile.
937
974
  void createAiServerConversation({
938
975
  agentId: agent,
939
976
  conversationId: effectiveConversationId,
@@ -974,12 +1011,17 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
974
1011
  >(null)
975
1012
 
976
1013
  const [lastLoopTrace, setLastLoopTrace] = React.useState<LoopTracePanelTrace | null>(null)
1014
+ const [isOwner, setIsOwner] = React.useState<boolean | null>(null)
977
1015
 
978
1016
  const abortRef = React.useRef<AbortController | null>(null)
979
1017
  const onErrorRef = React.useRef(onError)
980
1018
  React.useEffect(() => {
981
1019
  onErrorRef.current = onError
982
1020
  }, [onError])
1021
+ const onConversationNotFoundRef = React.useRef(onConversationNotFound)
1022
+ React.useEffect(() => {
1023
+ onConversationNotFoundRef.current = onConversationNotFound
1024
+ }, [onConversationNotFound])
983
1025
 
984
1026
  const emitError = React.useCallback((envelope: AiChatErrorEnvelope) => {
985
1027
  if (mountedRef.current) {
@@ -1319,6 +1361,7 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
1319
1361
  lastResponseDebug,
1320
1362
  conversationId: effectiveConversationId,
1321
1363
  lastLoopTrace,
1364
+ isOwner,
1322
1365
  sendMessage,
1323
1366
  cancel,
1324
1367
  reset,