@open-mercato/ui 0.6.4-develop.3949.1.adc3d0b3b1 → 0.6.4-develop.3962.1.70f30e284c
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/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/__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
|
@@ -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({
|