@open-mercato/ui 0.6.3-develop.3894.1.352abf4240 → 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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/ai/AiAssistantLauncher.js +61 -8
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +29 -6
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiChatSessions.js +31 -9
- package/dist/ai/AiChatSessions.js.map +2 -2
- package/dist/ai/AiDock.js +8 -1
- package/dist/ai/AiDock.js.map +2 -2
- package/dist/ai/ConversationShareButton.js +40 -0
- package/dist/ai/ConversationShareButton.js.map +7 -0
- package/dist/ai/ConversationShareDialog.js +234 -0
- package/dist/ai/ConversationShareDialog.js.map +7 -0
- package/dist/ai/conversation-store.js +4 -2
- package/dist/ai/conversation-store.js.map +2 -2
- package/dist/ai/index.js +4 -0
- package/dist/ai/index.js.map +2 -2
- package/dist/ai/useAiChat.js +20 -4
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/charts/BarChart.js +19 -76
- package/dist/backend/charts/BarChart.js.map +2 -2
- package/dist/backend/charts/BarChartImpl.js +91 -0
- package/dist/backend/charts/BarChartImpl.js.map +7 -0
- package/dist/backend/charts/LineChart.js +18 -91
- package/dist/backend/charts/LineChart.js.map +2 -2
- package/dist/backend/charts/LineChartImpl.js +112 -0
- package/dist/backend/charts/LineChartImpl.js.map +7 -0
- package/dist/backend/charts/PieChart.js +17 -62
- package/dist/backend/charts/PieChart.js.map +2 -2
- package/dist/backend/charts/PieChartImpl.js +76 -0
- package/dist/backend/charts/PieChartImpl.js.map +7 -0
- package/dist/backend/dashboard/DashboardScreen.js +9 -1
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/dashboard/widgetRegistry.js +18 -2
- package/dist/backend/dashboard/widgetRegistry.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +2 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useInjectionDataWidgets.js +12 -2
- package/dist/backend/injection/useInjectionDataWidgets.js.map +2 -2
- package/package.json +4 -5
- package/src/ai/AiAssistantLauncher.tsx +81 -5
- package/src/ai/AiChat.tsx +42 -2
- package/src/ai/AiChatSessions.tsx +55 -9
- package/src/ai/AiDock.tsx +13 -0
- package/src/ai/ConversationShareButton.tsx +39 -0
- package/src/ai/ConversationShareDialog.tsx +299 -0
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +6 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +97 -0
- package/src/ai/__tests__/AiChatSessions.test.tsx +228 -0
- package/src/ai/__tests__/AiDock.test.tsx +31 -1
- package/src/ai/__tests__/conversation-store.test.ts +74 -1
- package/src/ai/conversation-store.ts +22 -2
- package/src/ai/index.ts +2 -0
- package/src/ai/useAiChat.ts +47 -4
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +53 -0
- package/src/backend/charts/BarChart.tsx +21 -72
- package/src/backend/charts/BarChartImpl.tsx +99 -0
- package/src/backend/charts/LineChart.tsx +23 -85
- package/src/backend/charts/LineChartImpl.tsx +120 -0
- package/src/backend/charts/PieChart.tsx +19 -60
- package/src/backend/charts/PieChartImpl.tsx +82 -0
- package/src/backend/dashboard/DashboardScreen.tsx +9 -1
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +45 -0
- package/src/backend/dashboard/__tests__/widgetRegistry.test.ts +60 -0
- package/src/backend/dashboard/widgetRegistry.ts +23 -5
- package/src/backend/icons/lucideRegistry.generated.tsx +2 -0
- 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: ({
|
|
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<
|
|
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
|
-
|
|
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,
|
package/src/ai/useAiChat.ts
CHANGED
|
@@ -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
|
|
937
|
+
const transcriptResult = await loadAiServerTranscript(effectiveConversationId, { limit: 100 })
|
|
922
938
|
if (cancelled || latestStatusRef.current !== 'idle') return
|
|
923
939
|
|
|
924
|
-
if (
|
|
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 (!
|
|
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,
|