@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.
@@ -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<AiServerTranscriptResponse | null> {
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
- return result.ok ? result.data : null
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: {
@@ -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 transcript = await loadAiServerTranscript(effectiveConversationId, { limit: 100 })
937
+ const transcriptResult = await loadAiServerTranscript(effectiveConversationId, { limit: 100 })
929
938
  if (cancelled || latestStatusRef.current !== 'idle') return
930
939
 
931
- if (transcript) {
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 (!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.
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({