@open-mercato/ui 0.6.4-develop.3949.1.adc3d0b3b1 → 0.6.4-develop.3968.1.9d87f5fa16
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/dist/ai/AiChat.js +4 -2
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiChatSessions.js +30 -9
- package/dist/ai/AiChatSessions.js.map +2 -2
- package/dist/ai/AiDock.js +6 -1
- package/dist/ai/AiDock.js.map +2 -2
- package/dist/ai/ConversationShareDialog.js +6 -2
- package/dist/ai/ConversationShareDialog.js.map +2 -2
- package/dist/ai/conversation-store.js +2 -1
- package/dist/ai/conversation-store.js.map +2 -2
- package/dist/ai/useAiChat.js +15 -4
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +3 -1
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/package.json +3 -3
- package/src/ai/AiChat.tsx +10 -0
- package/src/ai/AiChatSessions.tsx +54 -9
- package/src/ai/AiDock.tsx +11 -0
- package/src/ai/ConversationShareDialog.tsx +8 -3
- 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 +18 -2
- package/src/ai/useAiChat.ts +35 -4
- package/src/backend/dashboard/DashboardScreen.tsx +3 -1
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +28 -0
|
@@ -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
|
})
|
|
@@ -50,6 +50,21 @@ export interface AiServerImportResponse {
|
|
|
50
50
|
skippedMessageCount: number
|
|
51
51
|
}
|
|
52
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
|
+
|
|
53
68
|
function readString(value: unknown): string | null {
|
|
54
69
|
return typeof value === 'string' && value.length > 0 ? value : null
|
|
55
70
|
}
|
|
@@ -147,13 +162,14 @@ export async function updateAiServerConversation(
|
|
|
147
162
|
export async function loadAiServerTranscript(
|
|
148
163
|
conversationId: string,
|
|
149
164
|
options: { limit?: number } = {},
|
|
150
|
-
): Promise<
|
|
165
|
+
): Promise<LoadTranscriptResult> {
|
|
151
166
|
const params = new URLSearchParams()
|
|
152
167
|
params.set('limit', String(options.limit ?? 100))
|
|
153
168
|
const result = await requestJson<AiServerTranscriptResponse>(
|
|
154
169
|
`${CONVERSATIONS_ENDPOINT}/${encodeURIComponent(conversationId)}?${params.toString()}`,
|
|
155
170
|
)
|
|
156
|
-
|
|
171
|
+
if (result.ok) return { ok: true, data: result.data }
|
|
172
|
+
return { ok: false, notFound: result.status === 404 }
|
|
157
173
|
}
|
|
158
174
|
|
|
159
175
|
export async function importAiLocalConversation(input: {
|
package/src/ai/useAiChat.ts
CHANGED
|
@@ -118,6 +118,15 @@ export interface UseAiChatInput {
|
|
|
118
118
|
* configured default (no override sent).
|
|
119
119
|
*/
|
|
120
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
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
export interface AiChatErrorEnvelope {
|
|
@@ -779,7 +788,7 @@ async function readErrorEnvelope(response: Response): Promise<AiChatErrorEnvelop
|
|
|
779
788
|
}
|
|
780
789
|
|
|
781
790
|
export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
782
|
-
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
|
|
783
792
|
|
|
784
793
|
// Minted once on mount when the caller does not supply a conversationId.
|
|
785
794
|
// The ref keeps the id stable across re-renders and is reused for every
|
|
@@ -925,10 +934,11 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
925
934
|
const localCandidate = readPersistedSession(agent, persistKey)
|
|
926
935
|
|
|
927
936
|
async function hydrateFromServer(): Promise<void> {
|
|
928
|
-
const
|
|
937
|
+
const transcriptResult = await loadAiServerTranscript(effectiveConversationId, { limit: 100 })
|
|
929
938
|
if (cancelled || latestStatusRef.current !== 'idle') return
|
|
930
939
|
|
|
931
|
-
if (
|
|
940
|
+
if (transcriptResult.ok) {
|
|
941
|
+
const transcript = transcriptResult.data
|
|
932
942
|
if (typeof transcript.conversation.isOwner === 'boolean') {
|
|
933
943
|
setIsOwner(transcript.conversation.isOwner)
|
|
934
944
|
}
|
|
@@ -942,8 +952,25 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
942
952
|
}
|
|
943
953
|
}
|
|
944
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
|
+
|
|
945
969
|
if (!localCandidate || localCandidate.messages.length === 0) {
|
|
946
|
-
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.
|
|
947
974
|
void createAiServerConversation({
|
|
948
975
|
agentId: agent,
|
|
949
976
|
conversationId: effectiveConversationId,
|
|
@@ -991,6 +1018,10 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
991
1018
|
React.useEffect(() => {
|
|
992
1019
|
onErrorRef.current = onError
|
|
993
1020
|
}, [onError])
|
|
1021
|
+
const onConversationNotFoundRef = React.useRef(onConversationNotFound)
|
|
1022
|
+
React.useEffect(() => {
|
|
1023
|
+
onConversationNotFoundRef.current = onConversationNotFound
|
|
1024
|
+
}, [onConversationNotFound])
|
|
994
1025
|
|
|
995
1026
|
const emitError = React.useCallback((envelope: AiChatErrorEnvelope) => {
|
|
996
1027
|
if (mountedRef.current) {
|
|
@@ -12,6 +12,7 @@ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboa
|
|
|
12
12
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
13
13
|
import { GripVertical, Info, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
|
|
14
14
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
15
|
+
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
15
16
|
import { InjectionSpot } from '../injection/InjectionSpot'
|
|
16
17
|
|
|
17
18
|
type DashboardWidgetSize = 'sm' | 'md' | 'lg'
|
|
@@ -92,6 +93,7 @@ function generateId(): string {
|
|
|
92
93
|
|
|
93
94
|
export function DashboardScreen() {
|
|
94
95
|
const t = useT()
|
|
96
|
+
const organizationScopeVersion = useOrganizationScopeVersion()
|
|
95
97
|
const [loading, setLoading] = React.useState(true)
|
|
96
98
|
const [error, setError] = React.useState<string | null>(null)
|
|
97
99
|
const [hasRegisteredWidgets, setHasRegisteredWidgets] = React.useState(true)
|
|
@@ -165,7 +167,7 @@ export function DashboardScreen() {
|
|
|
165
167
|
|
|
166
168
|
React.useEffect(() => {
|
|
167
169
|
load()
|
|
168
|
-
}, [load])
|
|
170
|
+
}, [load, organizationScopeVersion])
|
|
169
171
|
|
|
170
172
|
const metaById = React.useMemo(() => {
|
|
171
173
|
const map = new Map<string, WidgetMeta>()
|
|
@@ -20,6 +20,12 @@ jest.mock('../widgetRegistry', () => ({
|
|
|
20
20
|
loadDashboardWidgetModule: jest.fn(),
|
|
21
21
|
}))
|
|
22
22
|
|
|
23
|
+
let mockOrganizationScopeVersion = 0
|
|
24
|
+
|
|
25
|
+
jest.mock('@open-mercato/shared/lib/frontend/useOrganizationScope', () => ({
|
|
26
|
+
useOrganizationScopeVersion: () => mockOrganizationScopeVersion,
|
|
27
|
+
}))
|
|
28
|
+
|
|
23
29
|
jest.mock('next/navigation', () => ({
|
|
24
30
|
useRouter: () => ({
|
|
25
31
|
refresh: jest.fn(),
|
|
@@ -75,6 +81,7 @@ function MockWidget() {
|
|
|
75
81
|
describe('DashboardScreen', () => {
|
|
76
82
|
beforeEach(() => {
|
|
77
83
|
jest.resetAllMocks()
|
|
84
|
+
mockOrganizationScopeVersion = 0
|
|
78
85
|
;(getDashboardWidgets as jest.Mock).mockReturnValue([{ key: 'foo.loader', loader: jest.fn() }])
|
|
79
86
|
;(loadDashboardWidgetModule as jest.Mock).mockResolvedValue({
|
|
80
87
|
Widget: MockWidget,
|
|
@@ -99,6 +106,27 @@ describe('DashboardScreen', () => {
|
|
|
99
106
|
expect(await screen.findByText('Widget body')).toBeInTheDocument()
|
|
100
107
|
})
|
|
101
108
|
|
|
109
|
+
it('reloads the dashboard when the organization scope changes', async () => {
|
|
110
|
+
;(apiCall as jest.Mock).mockResolvedValue({
|
|
111
|
+
ok: true,
|
|
112
|
+
status: 200,
|
|
113
|
+
result: widgetResponse,
|
|
114
|
+
response: createMockResponse(200),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const { rerender } = renderWithProviders(<DashboardScreen />, { dict })
|
|
118
|
+
|
|
119
|
+
expect(await screen.findByText('Widget body')).toBeInTheDocument()
|
|
120
|
+
expect(apiCall).toHaveBeenCalledTimes(1)
|
|
121
|
+
|
|
122
|
+
mockOrganizationScopeVersion = 1
|
|
123
|
+
rerender(<DashboardScreen />)
|
|
124
|
+
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
expect(apiCall).toHaveBeenCalledTimes(2)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
102
130
|
it('shows an error when the layout request fails', async () => {
|
|
103
131
|
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
104
132
|
;(apiCall as jest.Mock).mockResolvedValue({
|