@open-mercato/ui 0.6.8-develop.6878.1.ab817e58ac → 0.6.8-develop.6881.1.8ea75912f2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/ai/AiChatSessions.tsx"],
4
- "sourcesContent": ["\"use client\"\n\n/**\n * Multi-tab AI chat sessions.\n *\n * Each agent can have several concurrent conversation threads (sessions).\n * The provider mirrors session metadata from the server-side conversation\n * store. localStorage remains a backward-compatible cache so existing browser\n * sessions can be imported rather than orphaned, but the server is the source\n * of truth whenever it is reachable.\n *\n * Sessions are partitioned into:\n * - `open` \u2192 currently shown in the tab strip\n * - `closed` \u2192 hidden but kept for the history dropdown\n *\n * Closing a tab moves it to `closed`. Re-opening from history flips it back\n * to `open` and surfaces it as the active tab. Renaming is a single\n * `name` field; falling back to the formatted creation date when the user\n * never named the session keeps the picker scannable.\n */\n\nimport * as React from 'react'\nimport {\n getCurrentOrganizationScope,\n subscribeOrganizationScopeChanged,\n} from '@open-mercato/shared/lib/frontend/organizationEvents'\nimport { readVersionedPreference, writeVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'\nimport {\n createAiServerConversation,\n listAiServerConversations,\n updateAiServerConversation,\n type AiServerConversation,\n} from './conversation-store'\n\n/**\n * Legacy app-global storage key used before tenant/org scoping.\n *\n * Kept only as a documented constant for grep / debugging. No code path\n * reads or writes it any more \u2014 the legacy entry is intentionally\n * abandoned (not migrated) because the origin scope of any data stored\n * under this key is unknown; silently importing it into the wrong scope\n * is worse than empty state. `listAiServerConversations` repopulates\n * sessions from the authoritative server source on first load.\n */\nexport const LEGACY_STORAGE_KEY = 'om-ai-chat-sessions-v1'\nconst STORAGE_KEY_PREFIX = 'om-ai-chat-sessions-v1'\nconst HISTORY_LIMIT = 50\n\nfunction getScopedStorageKey(\n tenantId: string | null | undefined,\n organizationId: string | null | undefined,\n): string {\n return `${STORAGE_KEY_PREFIX}:${tenantId ?? 'no-tenant'}:${organizationId ?? 'no-org'}`\n}\n\nexport type AiChatSessionStatus = 'open' | 'closed'\n\nexport interface AiChatSession {\n id: string\n agentId: string\n conversationId: string\n name?: string\n createdAt: number\n lastUsedAt: number\n status: AiChatSessionStatus\n}\n\ninterface AiChatSessionsState {\n sessions: AiChatSession[]\n activeByAgent: Record<string, string>\n}\n\ninterface AiChatSessionsApi {\n state: AiChatSessionsState\n /** Returns open sessions for `agentId`, ordered by creation. */\n getOpenSessions: (agentId: string) => AiChatSession[]\n /** Returns closed sessions (history) for `agentId`, newest first. */\n getClosedSessions: (agentId: string, limit?: number) => AiChatSession[]\n /** Returns the active session for `agentId`, or null if none. */\n getActiveSession: (agentId: string) => AiChatSession | null\n /** Creates and activates a fresh open session for `agentId`. */\n createSession: (agentId: string) => AiChatSession\n /** Closes a session (moves to history). If it was active, picks another. */\n closeSession: (sessionId: string) => void\n /** Re-opens a closed session and activates it. */\n reopenSession: (sessionId: string) => void\n /** Activates an open session. */\n setActiveSession: (sessionId: string) => void\n /** Renames a session \u2014 empty / whitespace clears the custom name. */\n renameSession: (sessionId: string, name: string) => void\n /** Bumps `lastUsedAt` on a session (call when activity happens). */\n touchSession: (sessionId: string) => void\n /** Ensures `agentId` has at least one open session and returns its id. */\n ensureSession: (agentId: string) => AiChatSession\n}\n\nconst AiChatSessionsContext = React.createContext<AiChatSessionsApi | null>(null)\n\nfunction makeId(): string {\n const g = globalThis as unknown as { crypto?: { randomUUID?: () => string } }\n if (g.crypto && typeof g.crypto.randomUUID === 'function') {\n try {\n return g.crypto.randomUUID()\n } catch {\n /* fall through */\n }\n }\n const rand = () => Math.random().toString(16).slice(2, 10)\n return `${Date.now().toString(16)}-${rand()}-${rand()}`\n}\n\n// Versioned-envelope discriminator for the persisted sessions cache. Bump when\n// the stored shape changes incompatibly; legacy bare `{ sessions, activeByAgent }`\n// values are migrated forward on the next write. See\n// `@open-mercato/shared/lib/browser/versionedPreference`.\nconst AI_CHAT_SESSIONS_VERSION = 1\n\nfunction isPersistedSessionsShape(value: unknown): value is Partial<AiChatSessionsState> {\n return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nexport function readPersisted(storageKey: string): AiChatSessionsState {\n const parsed = readVersionedPreference<Partial<AiChatSessionsState> | null>(\n storageKey,\n AI_CHAT_SESSIONS_VERSION,\n (value): value is Partial<AiChatSessionsState> | null => isPersistedSessionsShape(value),\n null,\n { legacyIsValid: (value): value is Partial<AiChatSessionsState> | null => isPersistedSessionsShape(value) },\n )\n if (!parsed) return { sessions: [], activeByAgent: {} }\n const sessions = Array.isArray(parsed.sessions)\n ? (parsed.sessions as unknown[])\n .filter((entry): entry is AiChatSession => {\n if (!entry || typeof entry !== 'object') return false\n const value = entry as Record<string, unknown>\n return (\n typeof value.id === 'string' &&\n typeof value.agentId === 'string' &&\n typeof value.conversationId === 'string' &&\n typeof value.createdAt === 'number' &&\n typeof value.lastUsedAt === 'number' &&\n (value.status === 'open' || value.status === 'closed')\n )\n })\n .map((entry) => {\n const value = entry as unknown as Record<string, unknown>\n const candidate = value.name\n return {\n ...entry,\n name: typeof candidate === 'string' ? candidate : undefined,\n }\n })\n : []\n const activeByAgent =\n parsed.activeByAgent && typeof parsed.activeByAgent === 'object'\n ? Object.fromEntries(\n Object.entries(parsed.activeByAgent as Record<string, unknown>).filter(\n (entry): entry is [string, string] =>\n typeof entry[0] === 'string' && typeof entry[1] === 'string',\n ),\n )\n : {}\n return { sessions, activeByAgent }\n}\n\nexport function writePersisted(storageKey: string, state: AiChatSessionsState): void {\n writeVersionedPreference(storageKey, AI_CHAT_SESSIONS_VERSION, state)\n}\n\nfunction serverConversationToSession(\n conversation: AiServerConversation,\n existing?: AiChatSession,\n): AiChatSession {\n const createdAt = Date.parse(conversation.createdAt)\n const updatedAt = Date.parse(conversation.lastMessageAt ?? conversation.updatedAt)\n return {\n id: existing?.id ?? conversation.conversationId,\n agentId: conversation.agentId,\n conversationId: conversation.conversationId,\n name: conversation.title ?? existing?.name,\n createdAt: Number.isFinite(createdAt) ? createdAt : existing?.createdAt ?? Date.now(),\n lastUsedAt: Number.isFinite(updatedAt) ? updatedAt : existing?.lastUsedAt ?? Date.now(),\n status: conversation.status,\n }\n}\n\nfunction mergeServerConversations(\n prev: AiChatSessionsState,\n conversations: AiServerConversation[],\n): AiChatSessionsState {\n const byConversationId = new Map(prev.sessions.map((session) => [session.conversationId, session]))\n const serverSessions = conversations.map((conversation) =>\n serverConversationToSession(conversation, byConversationId.get(conversation.conversationId)),\n )\n const serverIds = new Set(serverSessions.map((session) => session.conversationId))\n const localOnly = prev.sessions.filter((session) => !serverIds.has(session.conversationId))\n const sessions = [...serverSessions, ...localOnly]\n const activeByAgent = { ...prev.activeByAgent }\n const agentIds = new Set(sessions.map((session) => session.agentId))\n for (const agentId of agentIds) {\n const activeId = activeByAgent[agentId]\n const active = sessions.find((session) => session.id === activeId)\n if (active && active.status === 'open') continue\n const nextOpen = sessions\n .filter((session) => session.agentId === agentId && session.status === 'open')\n .sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]\n if (nextOpen) activeByAgent[agentId] = nextOpen.id\n else delete activeByAgent[agentId]\n }\n return { sessions, activeByAgent }\n}\n\nexport function AiChatSessionsProvider({ children }: { children: React.ReactNode }) {\n // Hydrate synchronously via a lazy initializer. The previous \"empty\n // state + post-mount load effect\" pattern had a window where the\n // persistence effect ran with the empty closure value (because the\n // hydrate effect's queued setState had not committed yet) and clobbered\n // localStorage with `[]` before the loaded state's re-render wrote it\n // back. The lazy initializer puts the loaded state into the very first\n // render, so the persistence effect always sees the real data.\n // `readPersisted` already short-circuits to an empty object on the\n // server (no `window`), so SSR stays consistent with the bare-bones\n // shell \u2014 actual session-dependent UI only renders after a user\n // interaction opens a chat surface.\n //\n // The storage key is scoped to the current tenant/organization so a\n // scope switch in the topbar does not surface another tenant's\n // sessions. `getCurrentOrganizationScope()` reads module-level state\n // populated by the topbar before the provider mounts; a `null` scope\n // (pre-resolution) lands in a harmless `no-tenant:no-org` bucket that\n // the scope-change subscription below immediately corrects when the\n // real scope arrives.\n const [storageKey, setStorageKey] = React.useState<string>(() => {\n const scope = getCurrentOrganizationScope()\n return getScopedStorageKey(scope.tenantId, scope.organizationId)\n })\n const [state, setState] = React.useState<AiChatSessionsState>(() => readPersisted(storageKey))\n\n React.useEffect(() => {\n return subscribeOrganizationScopeChanged((detail) => {\n const nextKey = getScopedStorageKey(detail.tenantId, detail.organizationId)\n if (nextKey === storageKey) return\n // React 18 batches both updates within the synchronous handler so\n // there is no intermediate render with a mismatched key/state pair.\n setStorageKey(nextKey)\n setState(readPersisted(nextKey))\n })\n }, [storageKey])\n\n React.useEffect(() => {\n writePersisted(storageKey, state)\n }, [storageKey, state])\n\n React.useEffect(() => {\n let cancelled = false\n void listAiServerConversations({ limit: 100 }).then((conversations) => {\n if (cancelled || !conversations) return\n setState((prev) => mergeServerConversations(prev, conversations))\n })\n return () => {\n cancelled = true\n }\n }, [storageKey])\n\n const update = React.useCallback(\n (mutator: (prev: AiChatSessionsState) => AiChatSessionsState) => {\n setState((prev) => mutator(prev))\n },\n [],\n )\n\n const getOpenSessions = React.useCallback(\n (agentId: string) =>\n state.sessions\n .filter((s) => s.agentId === agentId && s.status === 'open')\n .sort((a, b) => a.createdAt - b.createdAt),\n [state.sessions],\n )\n\n const getClosedSessions = React.useCallback(\n (agentId: string, limit = HISTORY_LIMIT) =>\n state.sessions\n .filter((s) => s.agentId === agentId && s.status === 'closed')\n .sort((a, b) => b.lastUsedAt - a.lastUsedAt)\n .slice(0, limit),\n [state.sessions],\n )\n\n const getActiveSession = React.useCallback(\n (agentId: string) => {\n const id = state.activeByAgent[agentId]\n if (!id) return null\n const session = state.sessions.find((s) => s.id === id)\n if (!session || session.status !== 'open') return null\n return session\n },\n [state.activeByAgent, state.sessions],\n )\n\n const createSession = React.useCallback((agentId: string): AiChatSession => {\n const session: AiChatSession = {\n id: makeId(),\n agentId,\n conversationId: makeId(),\n createdAt: Date.now(),\n lastUsedAt: Date.now(),\n status: 'open',\n }\n void createAiServerConversation({\n agentId: session.agentId,\n conversationId: session.conversationId,\n })\n update((prev) => ({\n sessions: [...prev.sessions, session],\n activeByAgent: { ...prev.activeByAgent, [agentId]: session.id },\n }))\n return session\n }, [update])\n\n const closeSession = React.useCallback(\n (sessionId: string) => {\n const serverTarget = state.sessions.find((s) => s.id === sessionId)\n if (serverTarget) {\n void updateAiServerConversation(serverTarget.conversationId, { status: 'closed' })\n }\n update((prev) => {\n const target = prev.sessions.find((s) => s.id === sessionId)\n if (!target) return prev\n const sessions = prev.sessions.map((s) =>\n s.id === sessionId ? { ...s, status: 'closed' as const, lastUsedAt: Date.now() } : s,\n )\n const activeByAgent = { ...prev.activeByAgent }\n if (activeByAgent[target.agentId] === sessionId) {\n // Pick the next open session for this agent (most-recent first).\n const fallback = sessions\n .filter((s) => s.agentId === target.agentId && s.status === 'open')\n .sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]\n if (fallback) {\n activeByAgent[target.agentId] = fallback.id\n } else {\n delete activeByAgent[target.agentId]\n }\n }\n return { sessions, activeByAgent }\n })\n },\n [state.sessions, update],\n )\n\n const reopenSession = React.useCallback(\n (sessionId: string) => {\n const serverTarget = state.sessions.find((s) => s.id === sessionId)\n if (serverTarget) {\n void updateAiServerConversation(serverTarget.conversationId, { status: 'open' })\n }\n update((prev) => {\n const target = prev.sessions.find((s) => s.id === sessionId)\n if (!target) return prev\n const sessions = prev.sessions.map((s) =>\n s.id === sessionId ? { ...s, status: 'open' as const, lastUsedAt: Date.now() } : s,\n )\n return {\n sessions,\n activeByAgent: { ...prev.activeByAgent, [target.agentId]: sessionId },\n }\n })\n },\n [state.sessions, update],\n )\n\n const setActiveSession = React.useCallback(\n (sessionId: string) => {\n update((prev) => {\n const target = prev.sessions.find((s) => s.id === sessionId)\n if (!target || target.status !== 'open') return prev\n if (prev.activeByAgent[target.agentId] === sessionId) return prev\n const sessions = prev.sessions.map((s) =>\n s.id === sessionId ? { ...s, lastUsedAt: Date.now() } : s,\n )\n return {\n sessions,\n activeByAgent: { ...prev.activeByAgent, [target.agentId]: sessionId },\n }\n })\n },\n [update],\n )\n\n const renameSession = React.useCallback(\n (sessionId: string, name: string) => {\n const trimmed = name.trim()\n const serverTarget = state.sessions.find((s) => s.id === sessionId)\n if (serverTarget) {\n void updateAiServerConversation(serverTarget.conversationId, {\n title: trimmed.length > 0 ? trimmed : null,\n })\n }\n update((prev) => ({\n ...prev,\n sessions: prev.sessions.map((s) =>\n s.id === sessionId\n ? { ...s, name: trimmed.length > 0 ? trimmed : undefined }\n : s,\n ),\n }))\n },\n [state.sessions, update],\n )\n\n const touchSession = React.useCallback(\n (sessionId: string) => {\n update((prev) => ({\n ...prev,\n sessions: prev.sessions.map((s) =>\n s.id === sessionId ? { ...s, lastUsedAt: Date.now() } : s,\n ),\n }))\n },\n [update],\n )\n\n const ensureSession = React.useCallback(\n (agentId: string): AiChatSession => {\n // Everything happens inside a single functional setState so we always\n // see the latest pending state (not a stale closure). React Strict\n // Mode double-invokes both effects AND setState updaters in dev,\n // which previously caused the auto-bootstrap path to mint two\n // sessions on first chat-pane open. The `mintedSession` closure\n // cache makes the updater itself idempotent across double-invokes\n // (one fresh id per `ensureSession` call, regardless of how many\n // times React replays the updater for purity testing); the\n // functional `prev` lookup handles the case where two queued\n // ensureSession calls resolve back-to-back \u2014 the second one sees\n // the first's pending append and short-circuits.\n let resolved: AiChatSession | null = null\n let mintedSession: AiChatSession | null = null\n setState((prev) => {\n const activeId = prev.activeByAgent[agentId]\n if (activeId) {\n const active = prev.sessions.find((s) => s.id === activeId)\n if (active && active.status === 'open') {\n resolved = active\n return prev\n }\n }\n const anyOpen = prev.sessions\n .filter((s) => s.agentId === agentId && s.status === 'open')\n .sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]\n if (anyOpen) {\n resolved = anyOpen\n return {\n ...prev,\n activeByAgent: { ...prev.activeByAgent, [agentId]: anyOpen.id },\n }\n }\n if (!mintedSession) {\n mintedSession = {\n id: makeId(),\n agentId,\n conversationId: makeId(),\n createdAt: Date.now(),\n lastUsedAt: Date.now(),\n status: 'open',\n }\n void createAiServerConversation({\n agentId: mintedSession.agentId,\n conversationId: mintedSession.conversationId,\n })\n }\n resolved = mintedSession\n return {\n sessions: [...prev.sessions, mintedSession],\n activeByAgent: { ...prev.activeByAgent, [agentId]: mintedSession.id },\n }\n })\n // `setState` returns synchronously after invoking the updater (twice\n // in Strict Mode dev), so `resolved` is guaranteed to be set here.\n return resolved as unknown as AiChatSession\n },\n [],\n )\n\n const api = React.useMemo<AiChatSessionsApi>(\n () => ({\n state,\n getOpenSessions,\n getClosedSessions,\n getActiveSession,\n createSession,\n closeSession,\n reopenSession,\n setActiveSession,\n renameSession,\n touchSession,\n ensureSession,\n }),\n [\n state,\n getOpenSessions,\n getClosedSessions,\n getActiveSession,\n createSession,\n closeSession,\n reopenSession,\n setActiveSession,\n renameSession,\n touchSession,\n ensureSession,\n ],\n )\n\n return <AiChatSessionsContext.Provider value={api}>{children}</AiChatSessionsContext.Provider>\n}\n\nexport function useAiChatSessions(): AiChatSessionsApi {\n const ctx = React.useContext(AiChatSessionsContext)\n if (ctx) return ctx\n // Fallback no-op API \u2014 keeps consumers safe when the provider is absent\n // (legacy code paths, isolated unit tests). Every method is a no-op so a\n // chat surface without the provider behaves like a single anonymous\n // session (the behavior shipped before the multi-tab work).\n return {\n state: { sessions: [], activeByAgent: {} },\n getOpenSessions: () => [],\n getClosedSessions: () => [],\n getActiveSession: () => null,\n createSession: (agentId) => ({\n id: 'noop',\n agentId,\n conversationId: 'noop',\n createdAt: Date.now(),\n lastUsedAt: Date.now(),\n status: 'open',\n }),\n closeSession: () => {},\n reopenSession: () => {},\n setActiveSession: () => {},\n renameSession: () => {},\n touchSession: () => {},\n ensureSession: (agentId) => ({\n id: 'noop',\n agentId,\n conversationId: 'noop',\n createdAt: Date.now(),\n lastUsedAt: Date.now(),\n status: 'open',\n }),\n }\n}\n\nexport function defaultSessionLabel(session: AiChatSession): string {\n if (session.name && session.name.trim().length > 0) return session.name.trim()\n const date = new Date(session.createdAt)\n if (Number.isNaN(date.getTime())) return 'Session'\n return date.toLocaleString(undefined, {\n month: 'short',\n day: 'numeric',\n hour: '2-digit',\n minute: '2-digit',\n })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\n/**\n * Multi-tab AI chat sessions.\n *\n * Each agent can have several concurrent conversation threads (sessions).\n * The provider mirrors session metadata from the server-side conversation\n * store. localStorage remains a backward-compatible cache so existing browser\n * sessions can be imported rather than orphaned, but the server is the source\n * of truth whenever it is reachable.\n *\n * Sessions are partitioned into:\n * - `open` \u2192 currently shown in the tab strip\n * - `closed` \u2192 hidden but kept for the history dropdown\n *\n * Closing a tab moves it to `closed`. Re-opening from history flips it back\n * to `open` and surfaces it as the active tab. Renaming is a single\n * `name` field; falling back to the formatted creation date when the user\n * never named the session keeps the picker scannable.\n */\n\nimport * as React from 'react'\nimport {\n getCurrentOrganizationScope,\n subscribeOrganizationScopeChanged,\n} from '@open-mercato/shared/lib/frontend/organizationEvents'\nimport { readVersionedPreference, writeVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n createAiServerConversation,\n listAiServerConversations,\n updateAiServerConversation,\n type AiServerConversation,\n} from './conversation-store'\n\nconst logger = createLogger('ui').child({ component: 'AiChatSessions' })\n\n/**\n * Legacy app-global storage key used before tenant/org scoping.\n *\n * Kept only as a documented constant for grep / debugging. No code path\n * reads or writes it any more \u2014 the legacy entry is intentionally\n * abandoned (not migrated) because the origin scope of any data stored\n * under this key is unknown; silently importing it into the wrong scope\n * is worse than empty state. `listAiServerConversations` repopulates\n * sessions from the authoritative server source on first load.\n */\nexport const LEGACY_STORAGE_KEY = 'om-ai-chat-sessions-v1'\nconst STORAGE_KEY_PREFIX = 'om-ai-chat-sessions-v1'\nconst HISTORY_LIMIT = 50\n\nfunction getScopedStorageKey(\n tenantId: string | null | undefined,\n organizationId: string | null | undefined,\n): string {\n return `${STORAGE_KEY_PREFIX}:${tenantId ?? 'no-tenant'}:${organizationId ?? 'no-org'}`\n}\n\nexport type AiChatSessionStatus = 'open' | 'closed'\n\nexport interface AiChatSession {\n id: string\n agentId: string\n conversationId: string\n name?: string\n createdAt: number\n lastUsedAt: number\n status: AiChatSessionStatus\n}\n\ninterface AiChatSessionsState {\n sessions: AiChatSession[]\n activeByAgent: Record<string, string>\n}\n\ninterface AiChatSessionsApi {\n state: AiChatSessionsState\n /** Returns open sessions for `agentId`, ordered by creation. */\n getOpenSessions: (agentId: string) => AiChatSession[]\n /** Returns closed sessions (history) for `agentId`, newest first. */\n getClosedSessions: (agentId: string, limit?: number) => AiChatSession[]\n /** Returns the active session for `agentId`, or null if none. */\n getActiveSession: (agentId: string) => AiChatSession | null\n /** Creates and activates a fresh open session for `agentId`. */\n createSession: (agentId: string) => AiChatSession\n /** Closes a session (moves to history). If it was active, picks another. */\n closeSession: (sessionId: string) => void\n /** Re-opens a closed session and activates it. */\n reopenSession: (sessionId: string) => void\n /** Activates an open session. */\n setActiveSession: (sessionId: string) => void\n /** Renames a session \u2014 empty / whitespace clears the custom name. */\n renameSession: (sessionId: string, name: string) => void\n /** Bumps `lastUsedAt` on a session (call when activity happens). */\n touchSession: (sessionId: string) => void\n /** Ensures `agentId` has at least one open session and returns its id. */\n ensureSession: (agentId: string) => AiChatSession\n}\n\nconst AiChatSessionsContext = React.createContext<AiChatSessionsApi | null>(null)\n\nfunction makeId(): string {\n const g = globalThis as unknown as { crypto?: { randomUUID?: () => string } }\n if (g.crypto && typeof g.crypto.randomUUID === 'function') {\n try {\n return g.crypto.randomUUID()\n } catch {\n /* fall through */\n }\n }\n const rand = () => Math.random().toString(16).slice(2, 10)\n return `${Date.now().toString(16)}-${rand()}-${rand()}`\n}\n\n// Versioned-envelope discriminator for the persisted sessions cache. Bump when\n// the stored shape changes incompatibly; legacy bare `{ sessions, activeByAgent }`\n// values are migrated forward on the next write. See\n// `@open-mercato/shared/lib/browser/versionedPreference`.\nconst AI_CHAT_SESSIONS_VERSION = 1\n\nfunction isPersistedSessionsShape(value: unknown): value is Partial<AiChatSessionsState> {\n return !!value && typeof value === 'object' && !Array.isArray(value)\n}\n\nexport function readPersisted(storageKey: string): AiChatSessionsState {\n const parsed = readVersionedPreference<Partial<AiChatSessionsState> | null>(\n storageKey,\n AI_CHAT_SESSIONS_VERSION,\n (value): value is Partial<AiChatSessionsState> | null => isPersistedSessionsShape(value),\n null,\n { legacyIsValid: (value): value is Partial<AiChatSessionsState> | null => isPersistedSessionsShape(value) },\n )\n if (!parsed) return { sessions: [], activeByAgent: {} }\n const sessions = Array.isArray(parsed.sessions)\n ? (parsed.sessions as unknown[])\n .filter((entry): entry is AiChatSession => {\n if (!entry || typeof entry !== 'object') return false\n const value = entry as Record<string, unknown>\n return (\n typeof value.id === 'string' &&\n typeof value.agentId === 'string' &&\n typeof value.conversationId === 'string' &&\n typeof value.createdAt === 'number' &&\n typeof value.lastUsedAt === 'number' &&\n (value.status === 'open' || value.status === 'closed')\n )\n })\n .map((entry) => {\n const value = entry as unknown as Record<string, unknown>\n const candidate = value.name\n return {\n ...entry,\n name: typeof candidate === 'string' ? candidate : undefined,\n }\n })\n : []\n const activeByAgent =\n parsed.activeByAgent && typeof parsed.activeByAgent === 'object'\n ? Object.fromEntries(\n Object.entries(parsed.activeByAgent as Record<string, unknown>).filter(\n (entry): entry is [string, string] =>\n typeof entry[0] === 'string' && typeof entry[1] === 'string',\n ),\n )\n : {}\n return { sessions, activeByAgent }\n}\n\nexport function writePersisted(storageKey: string, state: AiChatSessionsState): void {\n writeVersionedPreference(storageKey, AI_CHAT_SESSIONS_VERSION, state)\n}\n\nfunction serverConversationToSession(\n conversation: AiServerConversation,\n existing?: AiChatSession,\n): AiChatSession {\n const createdAt = Date.parse(conversation.createdAt)\n const updatedAt = Date.parse(conversation.lastMessageAt ?? conversation.updatedAt)\n return {\n id: existing?.id ?? conversation.conversationId,\n agentId: conversation.agentId,\n conversationId: conversation.conversationId,\n name: conversation.title ?? existing?.name,\n createdAt: Number.isFinite(createdAt) ? createdAt : existing?.createdAt ?? Date.now(),\n lastUsedAt: Number.isFinite(updatedAt) ? updatedAt : existing?.lastUsedAt ?? Date.now(),\n status: conversation.status,\n }\n}\n\nfunction mergeServerConversations(\n prev: AiChatSessionsState,\n conversations: AiServerConversation[],\n): AiChatSessionsState {\n const byConversationId = new Map(prev.sessions.map((session) => [session.conversationId, session]))\n const serverSessions = conversations.map((conversation) =>\n serverConversationToSession(conversation, byConversationId.get(conversation.conversationId)),\n )\n const serverIds = new Set(serverSessions.map((session) => session.conversationId))\n const localOnly = prev.sessions.filter((session) => !serverIds.has(session.conversationId))\n const sessions = [...serverSessions, ...localOnly]\n const activeByAgent = { ...prev.activeByAgent }\n const agentIds = new Set(sessions.map((session) => session.agentId))\n for (const agentId of agentIds) {\n const activeId = activeByAgent[agentId]\n const active = sessions.find((session) => session.id === activeId)\n if (active && active.status === 'open') continue\n const nextOpen = sessions\n .filter((session) => session.agentId === agentId && session.status === 'open')\n .sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]\n if (nextOpen) activeByAgent[agentId] = nextOpen.id\n else delete activeByAgent[agentId]\n }\n return { sessions, activeByAgent }\n}\n\nexport function AiChatSessionsProvider({ children }: { children: React.ReactNode }) {\n // Hydrate synchronously via a lazy initializer. The previous \"empty\n // state + post-mount load effect\" pattern had a window where the\n // persistence effect ran with the empty closure value (because the\n // hydrate effect's queued setState had not committed yet) and clobbered\n // localStorage with `[]` before the loaded state's re-render wrote it\n // back. The lazy initializer puts the loaded state into the very first\n // render, so the persistence effect always sees the real data.\n // `readPersisted` already short-circuits to an empty object on the\n // server (no `window`), so SSR stays consistent with the bare-bones\n // shell \u2014 actual session-dependent UI only renders after a user\n // interaction opens a chat surface.\n //\n // The storage key is scoped to the current tenant/organization so a\n // scope switch in the topbar does not surface another tenant's\n // sessions. `getCurrentOrganizationScope()` reads module-level state\n // populated by the topbar before the provider mounts; a `null` scope\n // (pre-resolution) lands in a harmless `no-tenant:no-org` bucket that\n // the scope-change subscription below immediately corrects when the\n // real scope arrives.\n const [storageKey, setStorageKey] = React.useState<string>(() => {\n const scope = getCurrentOrganizationScope()\n return getScopedStorageKey(scope.tenantId, scope.organizationId)\n })\n const [state, setState] = React.useState<AiChatSessionsState>(() => readPersisted(storageKey))\n\n React.useEffect(() => {\n return subscribeOrganizationScopeChanged((detail) => {\n const nextKey = getScopedStorageKey(detail.tenantId, detail.organizationId)\n if (nextKey === storageKey) return\n // React 18 batches both updates within the synchronous handler so\n // there is no intermediate render with a mismatched key/state pair.\n setStorageKey(nextKey)\n setState(readPersisted(nextKey))\n })\n }, [storageKey])\n\n React.useEffect(() => {\n writePersisted(storageKey, state)\n }, [storageKey, state])\n\n React.useEffect(() => {\n let cancelled = false\n listAiServerConversations({ limit: 100 })\n .then((conversations) => {\n if (cancelled) return\n if (!conversations) {\n logger.warn('Could not load server conversations; keeping the locally persisted sessions')\n return\n }\n setState((prev) => mergeServerConversations(prev, conversations))\n })\n .catch((err) => {\n if (cancelled) return\n logger.error('Failed to load server conversations', { err })\n })\n return () => {\n cancelled = true\n }\n }, [storageKey])\n\n const update = React.useCallback(\n (mutator: (prev: AiChatSessionsState) => AiChatSessionsState) => {\n setState((prev) => mutator(prev))\n },\n [],\n )\n\n const getOpenSessions = React.useCallback(\n (agentId: string) =>\n state.sessions\n .filter((s) => s.agentId === agentId && s.status === 'open')\n .sort((a, b) => a.createdAt - b.createdAt),\n [state.sessions],\n )\n\n const getClosedSessions = React.useCallback(\n (agentId: string, limit = HISTORY_LIMIT) =>\n state.sessions\n .filter((s) => s.agentId === agentId && s.status === 'closed')\n .sort((a, b) => b.lastUsedAt - a.lastUsedAt)\n .slice(0, limit),\n [state.sessions],\n )\n\n const getActiveSession = React.useCallback(\n (agentId: string) => {\n const id = state.activeByAgent[agentId]\n if (!id) return null\n const session = state.sessions.find((s) => s.id === id)\n if (!session || session.status !== 'open') return null\n return session\n },\n [state.activeByAgent, state.sessions],\n )\n\n const createSession = React.useCallback((agentId: string): AiChatSession => {\n const session: AiChatSession = {\n id: makeId(),\n agentId,\n conversationId: makeId(),\n createdAt: Date.now(),\n lastUsedAt: Date.now(),\n status: 'open',\n }\n void createAiServerConversation({\n agentId: session.agentId,\n conversationId: session.conversationId,\n })\n update((prev) => ({\n sessions: [...prev.sessions, session],\n activeByAgent: { ...prev.activeByAgent, [agentId]: session.id },\n }))\n return session\n }, [update])\n\n const closeSession = React.useCallback(\n (sessionId: string) => {\n const serverTarget = state.sessions.find((s) => s.id === sessionId)\n if (serverTarget) {\n void updateAiServerConversation(serverTarget.conversationId, { status: 'closed' })\n }\n update((prev) => {\n const target = prev.sessions.find((s) => s.id === sessionId)\n if (!target) return prev\n const sessions = prev.sessions.map((s) =>\n s.id === sessionId ? { ...s, status: 'closed' as const, lastUsedAt: Date.now() } : s,\n )\n const activeByAgent = { ...prev.activeByAgent }\n if (activeByAgent[target.agentId] === sessionId) {\n // Pick the next open session for this agent (most-recent first).\n const fallback = sessions\n .filter((s) => s.agentId === target.agentId && s.status === 'open')\n .sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]\n if (fallback) {\n activeByAgent[target.agentId] = fallback.id\n } else {\n delete activeByAgent[target.agentId]\n }\n }\n return { sessions, activeByAgent }\n })\n },\n [state.sessions, update],\n )\n\n const reopenSession = React.useCallback(\n (sessionId: string) => {\n const serverTarget = state.sessions.find((s) => s.id === sessionId)\n if (serverTarget) {\n void updateAiServerConversation(serverTarget.conversationId, { status: 'open' })\n }\n update((prev) => {\n const target = prev.sessions.find((s) => s.id === sessionId)\n if (!target) return prev\n const sessions = prev.sessions.map((s) =>\n s.id === sessionId ? { ...s, status: 'open' as const, lastUsedAt: Date.now() } : s,\n )\n return {\n sessions,\n activeByAgent: { ...prev.activeByAgent, [target.agentId]: sessionId },\n }\n })\n },\n [state.sessions, update],\n )\n\n const setActiveSession = React.useCallback(\n (sessionId: string) => {\n update((prev) => {\n const target = prev.sessions.find((s) => s.id === sessionId)\n if (!target || target.status !== 'open') return prev\n if (prev.activeByAgent[target.agentId] === sessionId) return prev\n const sessions = prev.sessions.map((s) =>\n s.id === sessionId ? { ...s, lastUsedAt: Date.now() } : s,\n )\n return {\n sessions,\n activeByAgent: { ...prev.activeByAgent, [target.agentId]: sessionId },\n }\n })\n },\n [update],\n )\n\n const renameSession = React.useCallback(\n (sessionId: string, name: string) => {\n const trimmed = name.trim()\n const serverTarget = state.sessions.find((s) => s.id === sessionId)\n if (serverTarget) {\n void updateAiServerConversation(serverTarget.conversationId, {\n title: trimmed.length > 0 ? trimmed : null,\n })\n }\n update((prev) => ({\n ...prev,\n sessions: prev.sessions.map((s) =>\n s.id === sessionId\n ? { ...s, name: trimmed.length > 0 ? trimmed : undefined }\n : s,\n ),\n }))\n },\n [state.sessions, update],\n )\n\n const touchSession = React.useCallback(\n (sessionId: string) => {\n update((prev) => ({\n ...prev,\n sessions: prev.sessions.map((s) =>\n s.id === sessionId ? { ...s, lastUsedAt: Date.now() } : s,\n ),\n }))\n },\n [update],\n )\n\n const ensureSession = React.useCallback(\n (agentId: string): AiChatSession => {\n // Everything happens inside a single functional setState so we always\n // see the latest pending state (not a stale closure). React Strict\n // Mode double-invokes both effects AND setState updaters in dev,\n // which previously caused the auto-bootstrap path to mint two\n // sessions on first chat-pane open. The `mintedSession` closure\n // cache makes the updater itself idempotent across double-invokes\n // (one fresh id per `ensureSession` call, regardless of how many\n // times React replays the updater for purity testing); the\n // functional `prev` lookup handles the case where two queued\n // ensureSession calls resolve back-to-back \u2014 the second one sees\n // the first's pending append and short-circuits.\n let resolved: AiChatSession | null = null\n let mintedSession: AiChatSession | null = null\n setState((prev) => {\n const activeId = prev.activeByAgent[agentId]\n if (activeId) {\n const active = prev.sessions.find((s) => s.id === activeId)\n if (active && active.status === 'open') {\n resolved = active\n return prev\n }\n }\n const anyOpen = prev.sessions\n .filter((s) => s.agentId === agentId && s.status === 'open')\n .sort((a, b) => b.lastUsedAt - a.lastUsedAt)[0]\n if (anyOpen) {\n resolved = anyOpen\n return {\n ...prev,\n activeByAgent: { ...prev.activeByAgent, [agentId]: anyOpen.id },\n }\n }\n if (!mintedSession) {\n mintedSession = {\n id: makeId(),\n agentId,\n conversationId: makeId(),\n createdAt: Date.now(),\n lastUsedAt: Date.now(),\n status: 'open',\n }\n void createAiServerConversation({\n agentId: mintedSession.agentId,\n conversationId: mintedSession.conversationId,\n })\n }\n resolved = mintedSession\n return {\n sessions: [...prev.sessions, mintedSession],\n activeByAgent: { ...prev.activeByAgent, [agentId]: mintedSession.id },\n }\n })\n // `setState` returns synchronously after invoking the updater (twice\n // in Strict Mode dev), so `resolved` is guaranteed to be set here.\n return resolved as unknown as AiChatSession\n },\n [],\n )\n\n const api = React.useMemo<AiChatSessionsApi>(\n () => ({\n state,\n getOpenSessions,\n getClosedSessions,\n getActiveSession,\n createSession,\n closeSession,\n reopenSession,\n setActiveSession,\n renameSession,\n touchSession,\n ensureSession,\n }),\n [\n state,\n getOpenSessions,\n getClosedSessions,\n getActiveSession,\n createSession,\n closeSession,\n reopenSession,\n setActiveSession,\n renameSession,\n touchSession,\n ensureSession,\n ],\n )\n\n return <AiChatSessionsContext.Provider value={api}>{children}</AiChatSessionsContext.Provider>\n}\n\nexport function useAiChatSessions(): AiChatSessionsApi {\n const ctx = React.useContext(AiChatSessionsContext)\n if (ctx) return ctx\n // Fallback no-op API \u2014 keeps consumers safe when the provider is absent\n // (legacy code paths, isolated unit tests). Every method is a no-op so a\n // chat surface without the provider behaves like a single anonymous\n // session (the behavior shipped before the multi-tab work).\n return {\n state: { sessions: [], activeByAgent: {} },\n getOpenSessions: () => [],\n getClosedSessions: () => [],\n getActiveSession: () => null,\n createSession: (agentId) => ({\n id: 'noop',\n agentId,\n conversationId: 'noop',\n createdAt: Date.now(),\n lastUsedAt: Date.now(),\n status: 'open',\n }),\n closeSession: () => {},\n reopenSession: () => {},\n setActiveSession: () => {},\n renameSession: () => {},\n touchSession: () => {},\n ensureSession: (agentId) => ({\n id: 'noop',\n agentId,\n conversationId: 'noop',\n createdAt: Date.now(),\n lastUsedAt: Date.now(),\n status: 'open',\n }),\n }\n}\n\nexport function defaultSessionLabel(session: AiChatSession): string {\n if (session.name && session.name.trim().length > 0) return session.name.trim()\n const date = new Date(session.createdAt)\n if (Number.isNaN(date.getTime())) return 'Session'\n return date.toLocaleString(undefined, {\n month: 'short',\n day: 'numeric',\n hour: '2-digit',\n minute: '2-digit',\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
+ import { createLogger } from "@open-mercato/shared/lib/logger";
3
4
  import { apiCallOrThrow } from "../../backend/utils/apiCall.js";
5
+ const logger = createLogger("ui").child({ component: "useAiPendingActionPolling" });
4
6
  const TERMINAL_STATUSES = [
5
7
  "confirmed",
6
8
  "cancelled",
@@ -66,6 +68,7 @@ function useAiPendingActionPolling(options) {
66
68
  }
67
69
  return result.pendingAction;
68
70
  } catch (err) {
71
+ logger.error("Failed to load the pending AI action", { err, pendingActionId });
69
72
  if (!mountedRef.current) return null;
70
73
  const message = err instanceof Error ? err.message : "Failed to load pending action.";
71
74
  setError({ message });
@@ -85,7 +88,11 @@ function useAiPendingActionPolling(options) {
85
88
  }
86
89
  setIsPolling(true);
87
90
  timerRef.current = setTimeout(async () => {
88
- await refresh();
91
+ try {
92
+ await refresh();
93
+ } catch (err) {
94
+ logger.error("Polling the pending AI action failed unexpectedly", { err });
95
+ }
89
96
  scheduleNext();
90
97
  }, intervalMs);
91
98
  }, [clearTimer, disabled, intervalMs, refresh]);
@@ -100,7 +107,10 @@ function useAiPendingActionPolling(options) {
100
107
  };
101
108
  }
102
109
  setIsPolling(true);
103
- void refresh().then(() => {
110
+ void refresh().catch((err) => {
111
+ logger.error("Loading the pending AI action failed unexpectedly", { err });
112
+ return null;
113
+ }).then(() => {
104
114
  if (!mountedRef.current) return;
105
115
  scheduleNext();
106
116
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/ai/parts/useAiPendingActionPolling.ts"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { apiCallOrThrow } from '../../backend/utils/apiCall'\nimport type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'\n\n/**\n * Shared polling hook for the Phase 3 mutation-approval cards (Step 5.10).\n *\n * Responsibilities:\n * - Fetch `GET /api/ai_assistant/ai/actions/:id` on mount, even when the\n * server previously streamed a preview card. This is the \"reconnect\" path:\n * after a page reload or navigation away+back, the card recovers the\n * current pending-action state instead of staying blank.\n * - Poll every 3 seconds while the status is non-terminal\n * (`pending` / `executing`). Terminal states (`confirmed`, `cancelled`,\n * `failed`, `expired`) stop polling.\n * - Expose a `refresh()` force-fetch helper the confirmation card uses after\n * a confirm POST races with the polling loop.\n *\n * The hook owns a single `setInterval`/`setTimeout` \u2014 unmounting clears\n * every outstanding timer. This is required for the Jest fake-timers\n * \"mount, unmount mid-poll\" test contract.\n */\n\nconst TERMINAL_STATUSES: ReadonlyArray<AiPendingActionCardStatus> = [\n 'confirmed',\n 'cancelled',\n 'failed',\n 'expired',\n]\n\nfunction isTerminal(status: AiPendingActionCardStatus | null): boolean {\n if (!status) return false\n return TERMINAL_STATUSES.includes(status)\n}\n\nexport interface UseAiPendingActionPollingOptions {\n pendingActionId: string\n /**\n * Poll interval in ms while the status is non-terminal. Defaults to 3000.\n */\n intervalMs?: number\n /**\n * Endpoint base. Override to point at a mock during tests.\n */\n endpoint?: string\n /**\n * When true, the hook does NOT schedule any network activity. Used by the\n * result card which already holds a terminal state and only needs to read\n * what the preview card fetched.\n */\n disabled?: boolean\n}\n\nexport interface AiPendingActionFetchResult {\n pendingAction: AiPendingActionCardAction | null\n error?: { code?: string; message: string } | null\n}\n\nexport interface UseAiPendingActionPollingResult {\n action: AiPendingActionCardAction | null\n status: AiPendingActionCardStatus | null\n isPolling: boolean\n error: { code?: string; message: string } | null\n refresh: () => Promise<AiPendingActionCardAction | null>\n}\n\nasync function fetchPendingAction(\n pendingActionId: string,\n endpoint: string,\n): Promise<AiPendingActionFetchResult> {\n const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`\n const call = await apiCallOrThrow<unknown>(url, { method: 'GET' })\n const body = call.result as\n | (Partial<AiPendingActionCardAction> & {\n pendingAction?: AiPendingActionCardAction\n error?: string\n code?: string\n })\n | null\n | undefined\n\n // The GET route returns the bare action object (`serializePendingActionForClient(row)`),\n // but earlier client code expected it under a `pendingAction` envelope.\n // Accept BOTH shapes so the cards work whether the dispatcher wraps or\n // not \u2014 bare-object payloads were the actual cause of empty\n // `fieldDiff` and missing `executionResult.error` displays in\n // MutationPreviewCard / ConfirmationCard / MutationResultCard.\n if (body && typeof body === 'object') {\n if (body.pendingAction) {\n return { pendingAction: body.pendingAction, error: null }\n }\n if (\n typeof (body as Partial<AiPendingActionCardAction>).id === 'string' &&\n typeof (body as Partial<AiPendingActionCardAction>).status === 'string'\n ) {\n return { pendingAction: body as AiPendingActionCardAction, error: null }\n }\n if (body.error) {\n return {\n pendingAction: null,\n error: { message: body.error, code: body.code },\n }\n }\n }\n return { pendingAction: null, error: null }\n}\n\nexport function useAiPendingActionPolling(\n options: UseAiPendingActionPollingOptions,\n): UseAiPendingActionPollingResult {\n const {\n pendingActionId,\n intervalMs = 3000,\n endpoint = '/api/ai_assistant/ai/actions',\n disabled = false,\n } = options\n\n const [action, setAction] = React.useState<AiPendingActionCardAction | null>(null)\n const [error, setError] = React.useState<{ code?: string; message: string } | null>(null)\n const [isPolling, setIsPolling] = React.useState<boolean>(!disabled)\n\n const mountedRef = React.useRef(true)\n const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n const statusRef = React.useRef<AiPendingActionCardStatus | null>(null)\n\n const clearTimer = React.useCallback(() => {\n if (timerRef.current) {\n clearTimeout(timerRef.current)\n timerRef.current = null\n }\n }, [])\n\n const refresh = React.useCallback(async (): Promise<AiPendingActionCardAction | null> => {\n if (!pendingActionId) return null\n try {\n const result = await fetchPendingAction(pendingActionId, endpoint)\n if (!mountedRef.current) return result.pendingAction ?? null\n if (result.error) {\n setError(result.error)\n } else {\n setError(null)\n }\n if (result.pendingAction) {\n setAction(result.pendingAction)\n statusRef.current = result.pendingAction.status\n }\n return result.pendingAction\n } catch (err) {\n if (!mountedRef.current) return null\n const message = err instanceof Error ? err.message : 'Failed to load pending action.'\n setError({ message })\n return null\n }\n }, [endpoint, pendingActionId])\n\n const scheduleNext = React.useCallback(() => {\n clearTimer()\n if (!mountedRef.current) return\n if (disabled) {\n setIsPolling(false)\n return\n }\n if (isTerminal(statusRef.current)) {\n setIsPolling(false)\n return\n }\n setIsPolling(true)\n timerRef.current = setTimeout(async () => {\n await refresh()\n scheduleNext()\n }, intervalMs)\n }, [clearTimer, disabled, intervalMs, refresh])\n\n React.useEffect(() => {\n mountedRef.current = true\n statusRef.current = null\n if (disabled) {\n setIsPolling(false)\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n }\n setIsPolling(true)\n // Always fetch on mount \u2014 the \"reconnect behavior\" guarantee.\n void refresh().then(() => {\n if (!mountedRef.current) return\n scheduleNext()\n })\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [pendingActionId, endpoint, intervalMs, disabled])\n\n const status = action?.status ?? null\n\n return {\n action,\n status,\n isPolling: isPolling && !isTerminal(status),\n error,\n refresh,\n }\n}\n\nexport default useAiPendingActionPolling\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { apiCallOrThrow } from '../../backend/utils/apiCall'\nimport type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'\n\nconst logger = createLogger('ui').child({ component: 'useAiPendingActionPolling' })\n\n/**\n * Shared polling hook for the Phase 3 mutation-approval cards (Step 5.10).\n *\n * Responsibilities:\n * - Fetch `GET /api/ai_assistant/ai/actions/:id` on mount, even when the\n * server previously streamed a preview card. This is the \"reconnect\" path:\n * after a page reload or navigation away+back, the card recovers the\n * current pending-action state instead of staying blank.\n * - Poll every 3 seconds while the status is non-terminal\n * (`pending` / `executing`). Terminal states (`confirmed`, `cancelled`,\n * `failed`, `expired`) stop polling.\n * - Expose a `refresh()` force-fetch helper the confirmation card uses after\n * a confirm POST races with the polling loop.\n *\n * The hook owns a single `setInterval`/`setTimeout` \u2014 unmounting clears\n * every outstanding timer. This is required for the Jest fake-timers\n * \"mount, unmount mid-poll\" test contract.\n */\n\nconst TERMINAL_STATUSES: ReadonlyArray<AiPendingActionCardStatus> = [\n 'confirmed',\n 'cancelled',\n 'failed',\n 'expired',\n]\n\nfunction isTerminal(status: AiPendingActionCardStatus | null): boolean {\n if (!status) return false\n return TERMINAL_STATUSES.includes(status)\n}\n\nexport interface UseAiPendingActionPollingOptions {\n pendingActionId: string\n /**\n * Poll interval in ms while the status is non-terminal. Defaults to 3000.\n */\n intervalMs?: number\n /**\n * Endpoint base. Override to point at a mock during tests.\n */\n endpoint?: string\n /**\n * When true, the hook does NOT schedule any network activity. Used by the\n * result card which already holds a terminal state and only needs to read\n * what the preview card fetched.\n */\n disabled?: boolean\n}\n\nexport interface AiPendingActionFetchResult {\n pendingAction: AiPendingActionCardAction | null\n error?: { code?: string; message: string } | null\n}\n\nexport interface UseAiPendingActionPollingResult {\n action: AiPendingActionCardAction | null\n status: AiPendingActionCardStatus | null\n isPolling: boolean\n error: { code?: string; message: string } | null\n refresh: () => Promise<AiPendingActionCardAction | null>\n}\n\nasync function fetchPendingAction(\n pendingActionId: string,\n endpoint: string,\n): Promise<AiPendingActionFetchResult> {\n const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`\n const call = await apiCallOrThrow<unknown>(url, { method: 'GET' })\n const body = call.result as\n | (Partial<AiPendingActionCardAction> & {\n pendingAction?: AiPendingActionCardAction\n error?: string\n code?: string\n })\n | null\n | undefined\n\n // The GET route returns the bare action object (`serializePendingActionForClient(row)`),\n // but earlier client code expected it under a `pendingAction` envelope.\n // Accept BOTH shapes so the cards work whether the dispatcher wraps or\n // not \u2014 bare-object payloads were the actual cause of empty\n // `fieldDiff` and missing `executionResult.error` displays in\n // MutationPreviewCard / ConfirmationCard / MutationResultCard.\n if (body && typeof body === 'object') {\n if (body.pendingAction) {\n return { pendingAction: body.pendingAction, error: null }\n }\n if (\n typeof (body as Partial<AiPendingActionCardAction>).id === 'string' &&\n typeof (body as Partial<AiPendingActionCardAction>).status === 'string'\n ) {\n return { pendingAction: body as AiPendingActionCardAction, error: null }\n }\n if (body.error) {\n return {\n pendingAction: null,\n error: { message: body.error, code: body.code },\n }\n }\n }\n return { pendingAction: null, error: null }\n}\n\nexport function useAiPendingActionPolling(\n options: UseAiPendingActionPollingOptions,\n): UseAiPendingActionPollingResult {\n const {\n pendingActionId,\n intervalMs = 3000,\n endpoint = '/api/ai_assistant/ai/actions',\n disabled = false,\n } = options\n\n const [action, setAction] = React.useState<AiPendingActionCardAction | null>(null)\n const [error, setError] = React.useState<{ code?: string; message: string } | null>(null)\n const [isPolling, setIsPolling] = React.useState<boolean>(!disabled)\n\n const mountedRef = React.useRef(true)\n const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n const statusRef = React.useRef<AiPendingActionCardStatus | null>(null)\n\n const clearTimer = React.useCallback(() => {\n if (timerRef.current) {\n clearTimeout(timerRef.current)\n timerRef.current = null\n }\n }, [])\n\n const refresh = React.useCallback(async (): Promise<AiPendingActionCardAction | null> => {\n if (!pendingActionId) return null\n try {\n const result = await fetchPendingAction(pendingActionId, endpoint)\n if (!mountedRef.current) return result.pendingAction ?? null\n if (result.error) {\n setError(result.error)\n } else {\n setError(null)\n }\n if (result.pendingAction) {\n setAction(result.pendingAction)\n statusRef.current = result.pendingAction.status\n }\n return result.pendingAction\n } catch (err) {\n logger.error('Failed to load the pending AI action', { err, pendingActionId })\n if (!mountedRef.current) return null\n const message = err instanceof Error ? err.message : 'Failed to load pending action.'\n setError({ message })\n return null\n }\n }, [endpoint, pendingActionId])\n\n const scheduleNext = React.useCallback(() => {\n clearTimer()\n if (!mountedRef.current) return\n if (disabled) {\n setIsPolling(false)\n return\n }\n if (isTerminal(statusRef.current)) {\n setIsPolling(false)\n return\n }\n setIsPolling(true)\n timerRef.current = setTimeout(async () => {\n try {\n await refresh()\n } catch (err) {\n logger.error('Polling the pending AI action failed unexpectedly', { err })\n }\n scheduleNext()\n }, intervalMs)\n }, [clearTimer, disabled, intervalMs, refresh])\n\n React.useEffect(() => {\n mountedRef.current = true\n statusRef.current = null\n if (disabled) {\n setIsPolling(false)\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n }\n setIsPolling(true)\n // Always fetch on mount \u2014 the \"reconnect behavior\" guarantee. The catch\n // runs before the scheduling step so a rejected first fetch degrades to a\n // logged error instead of silently killing the polling loop.\n void refresh()\n .catch((err) => {\n logger.error('Loading the pending AI action failed unexpectedly', { err })\n return null\n })\n .then(() => {\n if (!mountedRef.current) return\n scheduleNext()\n })\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [pendingActionId, endpoint, intervalMs, disabled])\n\n const status = action?.status ?? null\n\n return {\n action,\n status,\n isPolling: isPolling && !isTerminal(status),\n error,\n refresh,\n }\n}\n\nexport default useAiPendingActionPolling\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.8-develop.6878.1.ab817e58ac",
3
+ "version": "0.6.8-develop.6881.1.8ea75912f2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,14 +155,14 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.8-develop.6878.1.ab817e58ac",
158
+ "@open-mercato/shared": "0.6.8-develop.6881.1.8ea75912f2",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
164
  "@figma/code-connect": "^1.3.4",
165
- "@open-mercato/shared": "0.6.8-develop.6878.1.ab817e58ac",
165
+ "@open-mercato/shared": "0.6.8-develop.6881.1.8ea75912f2",
166
166
  "@testing-library/dom": "^10.4.1",
167
167
  "@testing-library/jest-dom": "^7.0.0",
168
168
  "@testing-library/react": "^16.3.1",
package/src/ai/AiChat.tsx CHANGED
@@ -98,40 +98,69 @@ interface ModelsApiResponse {
98
98
  providers: ModelPickerProvider[]
99
99
  }
100
100
 
101
+ type AgentModelsStatus = 'loading' | 'ready' | 'failed'
102
+
103
+ function readErrorStatus(error: unknown): number | undefined {
104
+ if (typeof error !== 'object' || error === null) return undefined
105
+ const { status } = error as { status?: unknown }
106
+ return typeof status === 'number' ? status : undefined
107
+ }
108
+
109
+ function logModelsFailure(status: number | undefined, details: Record<string, unknown>): void {
110
+ const message = 'Failed to load AI agent models'
111
+ if (status === 401 || status === 403) {
112
+ logger.warn(message, details)
113
+ return
114
+ }
115
+ logger.error(message, details)
116
+ }
117
+
101
118
  function useAgentModels(agent: string): {
102
119
  providers: ModelPickerProvider[]
103
120
  allowRuntimeOverride: boolean
104
121
  allowRuntimeModelOverride: boolean
105
122
  defaultLabel: string | null
106
- loaded: boolean
123
+ status: AgentModelsStatus
107
124
  } {
108
125
  const [providers, setProviders] = React.useState<ModelPickerProvider[]>([])
109
126
  const [allowRuntimeOverride, setAllowRuntimeOverride] = React.useState(false)
110
127
  const [defaultLabel, setDefaultLabel] = React.useState<string | null>(null)
111
- const [loaded, setLoaded] = React.useState(false)
128
+ const [status, setStatus] = React.useState<AgentModelsStatus>('loading')
112
129
 
113
130
  React.useEffect(() => {
131
+ let cancelled = false
114
132
  const modelsUrl = `/api/ai_assistant/ai/agents/${encodeURIComponent(agent)}/models`
115
- setLoaded(false)
133
+ setStatus('loading')
116
134
  setDefaultLabel(null)
117
- void apiCall<ModelsApiResponse>(modelsUrl).then((result) => {
118
- if (!result.ok || !result.result) {
119
- setLoaded(true)
120
- return
121
- }
122
- const effectiveAllowRuntimeOverride =
123
- result.result.allowRuntimeOverride ?? result.result.allowRuntimeModelOverride ?? false
124
- setAllowRuntimeOverride(effectiveAllowRuntimeOverride)
125
- setProviders(result.result.providers)
126
- setDefaultLabel(
127
- result.result.defaultProviderName && result.result.defaultModelName
128
- ? `${result.result.defaultProviderName} / ${result.result.defaultModelName}`
129
- : result.result.defaultProviderId && result.result.defaultModelId
130
- ? `${result.result.defaultProviderId} / ${result.result.defaultModelId}`
131
- : null,
132
- )
133
- setLoaded(true)
134
- })
135
+ apiCall<ModelsApiResponse>(modelsUrl)
136
+ .then((result) => {
137
+ if (cancelled) return
138
+ if (!result.ok || !result.result) {
139
+ logModelsFailure(result.status, { agent, status: result.status })
140
+ setStatus('failed')
141
+ return
142
+ }
143
+ const effectiveAllowRuntimeOverride =
144
+ result.result.allowRuntimeOverride ?? result.result.allowRuntimeModelOverride ?? false
145
+ setAllowRuntimeOverride(effectiveAllowRuntimeOverride)
146
+ setProviders(result.result.providers)
147
+ setDefaultLabel(
148
+ result.result.defaultProviderName && result.result.defaultModelName
149
+ ? `${result.result.defaultProviderName} / ${result.result.defaultModelName}`
150
+ : result.result.defaultProviderId && result.result.defaultModelId
151
+ ? `${result.result.defaultProviderId} / ${result.result.defaultModelId}`
152
+ : null,
153
+ )
154
+ setStatus('ready')
155
+ })
156
+ .catch((err) => {
157
+ if (cancelled) return
158
+ logModelsFailure(readErrorStatus(err), { agent, err })
159
+ setStatus('failed')
160
+ })
161
+ return () => {
162
+ cancelled = true
163
+ }
135
164
  }, [agent])
136
165
 
137
166
  return {
@@ -139,7 +168,7 @@ function useAgentModels(agent: string): {
139
168
  allowRuntimeOverride,
140
169
  allowRuntimeModelOverride: allowRuntimeOverride,
141
170
  defaultLabel,
142
- loaded,
171
+ status,
143
172
  }
144
173
  }
145
174
 
@@ -1213,7 +1242,7 @@ export function AiChat({
1213
1242
  allowRuntimeOverride,
1214
1243
  allowRuntimeModelOverride,
1215
1244
  defaultLabel: modelDefaultLabel,
1216
- loaded: modelProvidersLoaded,
1245
+ status: modelProvidersStatus,
1217
1246
  } = useAgentModels(agent)
1218
1247
 
1219
1248
  const [modelPickerValue, setModelPickerValue] = React.useState<ModelPickerValue | null>(() =>
@@ -1221,7 +1250,7 @@ export function AiChat({
1221
1250
  )
1222
1251
 
1223
1252
  const effectiveModelPickerValue = React.useMemo(() => {
1224
- if (!modelProvidersLoaded || !allowRuntimeOverride || modelProviders.length === 0) {
1253
+ if (modelProvidersStatus !== 'ready' || !allowRuntimeOverride || modelProviders.length === 0) {
1225
1254
  return null
1226
1255
  }
1227
1256
  if (modelPickerValue && isModelPickerValueAvailable(modelPickerValue, modelProviders)) {
@@ -1232,7 +1261,7 @@ export function AiChat({
1232
1261
  allowRuntimeOverride,
1233
1262
  modelPickerValue,
1234
1263
  modelProviders,
1235
- modelProvidersLoaded,
1264
+ modelProvidersStatus,
1236
1265
  ])
1237
1266
 
1238
1267
  React.useEffect(() => {
@@ -1240,7 +1269,7 @@ export function AiChat({
1240
1269
  }, [agent])
1241
1270
 
1242
1271
  React.useEffect(() => {
1243
- if (!modelProvidersLoaded) return
1272
+ if (modelProvidersStatus !== 'ready') return
1244
1273
  if (!allowRuntimeModelOverride || modelProviders.length === 0) {
1245
1274
  if (modelPickerValue !== null) {
1246
1275
  setModelPickerValue(null)
@@ -1257,7 +1286,7 @@ export function AiChat({
1257
1286
  allowRuntimeModelOverride,
1258
1287
  modelPickerValue,
1259
1288
  modelProviders,
1260
- modelProvidersLoaded,
1289
+ modelProvidersStatus,
1261
1290
  ])
1262
1291
 
1263
1292
  const handleModelPickerChange = React.useCallback(
@@ -25,6 +25,7 @@ import {
25
25
  subscribeOrganizationScopeChanged,
26
26
  } from '@open-mercato/shared/lib/frontend/organizationEvents'
27
27
  import { readVersionedPreference, writeVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
28
+ import { createLogger } from '@open-mercato/shared/lib/logger'
28
29
  import {
29
30
  createAiServerConversation,
30
31
  listAiServerConversations,
@@ -32,6 +33,8 @@ import {
32
33
  type AiServerConversation,
33
34
  } from './conversation-store'
34
35
 
36
+ const logger = createLogger('ui').child({ component: 'AiChatSessions' })
37
+
35
38
  /**
36
39
  * Legacy app-global storage key used before tenant/org scoping.
37
40
  *
@@ -253,10 +256,19 @@ export function AiChatSessionsProvider({ children }: { children: React.ReactNode
253
256
 
254
257
  React.useEffect(() => {
255
258
  let cancelled = false
256
- void listAiServerConversations({ limit: 100 }).then((conversations) => {
257
- if (cancelled || !conversations) return
258
- setState((prev) => mergeServerConversations(prev, conversations))
259
- })
259
+ listAiServerConversations({ limit: 100 })
260
+ .then((conversations) => {
261
+ if (cancelled) return
262
+ if (!conversations) {
263
+ logger.warn('Could not load server conversations; keeping the locally persisted sessions')
264
+ return
265
+ }
266
+ setState((prev) => mergeServerConversations(prev, conversations))
267
+ })
268
+ .catch((err) => {
269
+ if (cancelled) return
270
+ logger.error('Failed to load server conversations', { err })
271
+ })
260
272
  return () => {
261
273
  cancelled = true
262
274
  }
@@ -56,10 +56,28 @@ jest.mock('../../backend/utils/apiCall', () => ({
56
56
  })),
57
57
  }))
58
58
 
59
+ jest.mock('@open-mercato/shared/lib/logger', () => {
60
+ const mocked = {
61
+ debug: jest.fn(),
62
+ info: jest.fn(),
63
+ warn: jest.fn(),
64
+ error: jest.fn(),
65
+ child: jest.fn(),
66
+ }
67
+ mocked.child.mockImplementation(() => mocked)
68
+ return { createLogger: jest.fn(() => mocked) }
69
+ })
70
+
71
+ import { createLogger } from '@open-mercato/shared/lib/logger'
59
72
  import { apiFetch } from '../../backend/utils/api'
60
73
  import { apiCall } from '../../backend/utils/apiCall'
61
74
  import { AiChat } from '../AiChat'
62
75
 
76
+ const loggerError = createLogger('ui').error as jest.Mock
77
+ const loggerWarn = createLogger('ui').warn as jest.Mock
78
+
79
+ const MODEL_PICKER_STORAGE_KEY = 'om-ai-model-picker:customers.account_assistant'
80
+
63
81
  let lastResizeObserver: MockResizeObserver | null = null
64
82
 
65
83
  class MockResizeObserver {
@@ -241,7 +259,7 @@ describe('<AiChat>', () => {
241
259
  },
242
260
  })
243
261
  window.localStorage.setItem(
244
- 'om-ai-model-picker:customers.account_assistant',
262
+ MODEL_PICKER_STORAGE_KEY,
245
263
  JSON.stringify({ providerId: 'openai', modelId: 'gpt-4o' }),
246
264
  )
247
265
 
@@ -254,7 +272,134 @@ describe('<AiChat>', () => {
254
272
  )
255
273
  })
256
274
  await waitFor(() => {
257
- expect(window.localStorage.getItem('om-ai-model-picker:customers.account_assistant')).toBeNull()
275
+ expect(window.localStorage.getItem(MODEL_PICKER_STORAGE_KEY)).toBeNull()
276
+ })
277
+ })
278
+
279
+ it('logs and keeps the stored model picker value when the models endpoint errors', async () => {
280
+ const apiCallMock = apiCall as unknown as jest.Mock
281
+ apiCallMock.mockResolvedValueOnce({ ok: false, status: 500, result: null })
282
+ const storedValue = JSON.stringify({ providerId: 'openai', modelId: 'gpt-4o' })
283
+ window.localStorage.setItem(MODEL_PICKER_STORAGE_KEY, storedValue)
284
+
285
+ renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
286
+
287
+ await waitFor(() => {
288
+ expect(loggerError).toHaveBeenCalledWith('Failed to load AI agent models', {
289
+ agent: 'customers.account_assistant',
290
+ status: 500,
291
+ })
292
+ })
293
+ await act(async () => {})
294
+
295
+ expect(window.localStorage.getItem(MODEL_PICKER_STORAGE_KEY)).toBe(storedValue)
296
+ expect(screen.getByLabelText('Message composer')).toBeInTheDocument()
297
+ })
298
+
299
+ it('logs and keeps the stored model picker value when the models request rejects', async () => {
300
+ const apiCallMock = apiCall as unknown as jest.Mock
301
+ const failure = new Error('[internal] models endpoint unreachable')
302
+ apiCallMock.mockRejectedValueOnce(failure)
303
+ const storedValue = JSON.stringify({ providerId: 'openai', modelId: 'gpt-4o' })
304
+ window.localStorage.setItem(MODEL_PICKER_STORAGE_KEY, storedValue)
305
+
306
+ renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
307
+
308
+ await waitFor(() => {
309
+ expect(loggerError).toHaveBeenCalledWith('Failed to load AI agent models', {
310
+ agent: 'customers.account_assistant',
311
+ err: failure,
312
+ })
313
+ })
314
+ await act(async () => {})
315
+
316
+ expect(window.localStorage.getItem(MODEL_PICKER_STORAGE_KEY)).toBe(storedValue)
317
+ expect(screen.getByLabelText('Message composer')).toBeInTheDocument()
318
+ })
319
+
320
+ it('logs a denied models response at warning level and keeps the stored model picker value', async () => {
321
+ const apiCallMock = apiCall as unknown as jest.Mock
322
+ apiCallMock.mockResolvedValueOnce({ ok: false, status: 403, result: null })
323
+ const storedValue = JSON.stringify({ providerId: 'openai', modelId: 'gpt-4o' })
324
+ window.localStorage.setItem(MODEL_PICKER_STORAGE_KEY, storedValue)
325
+
326
+ renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
327
+
328
+ await waitFor(() => {
329
+ expect(loggerWarn).toHaveBeenCalledWith('Failed to load AI agent models', {
330
+ agent: 'customers.account_assistant',
331
+ status: 403,
332
+ })
333
+ })
334
+ await act(async () => {})
335
+
336
+ expect(loggerError).not.toHaveBeenCalledWith(
337
+ 'Failed to load AI agent models',
338
+ expect.anything(),
339
+ )
340
+ expect(window.localStorage.getItem(MODEL_PICKER_STORAGE_KEY)).toBe(storedValue)
341
+ })
342
+
343
+ it('logs a rejected models request at warning level when the error carries a denied status', async () => {
344
+ const apiCallMock = apiCall as unknown as jest.Mock
345
+ const failure = Object.assign(new Error('[internal] Forbidden'), { status: 403 })
346
+ apiCallMock.mockRejectedValueOnce(failure)
347
+ const storedValue = JSON.stringify({ providerId: 'openai', modelId: 'gpt-4o' })
348
+ window.localStorage.setItem(MODEL_PICKER_STORAGE_KEY, storedValue)
349
+
350
+ renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
351
+
352
+ await waitFor(() => {
353
+ expect(loggerWarn).toHaveBeenCalledWith('Failed to load AI agent models', {
354
+ agent: 'customers.account_assistant',
355
+ err: failure,
356
+ })
357
+ })
358
+ await act(async () => {})
359
+
360
+ expect(loggerError).not.toHaveBeenCalledWith(
361
+ 'Failed to load AI agent models',
362
+ expect.anything(),
363
+ )
364
+ expect(window.localStorage.getItem(MODEL_PICKER_STORAGE_KEY)).toBe(storedValue)
365
+ })
366
+
367
+ it('does not render the model picker while the models request is still in flight', async () => {
368
+ const apiCallMock = apiCall as unknown as jest.Mock
369
+ let resolveModels: ((value: unknown) => void) | null = null
370
+ apiCallMock.mockImplementationOnce(
371
+ () =>
372
+ new Promise((resolve) => {
373
+ resolveModels = resolve
374
+ }),
375
+ )
376
+
377
+ renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
378
+
379
+ expect(screen.queryByRole('button', { name: 'Select AI model' })).not.toBeInTheDocument()
380
+
381
+ await act(async () => {
382
+ resolveModels?.({
383
+ ok: true,
384
+ status: 200,
385
+ result: {
386
+ agentId: 'customers.account_assistant',
387
+ allowRuntimeModelOverride: true,
388
+ defaultProviderId: 'openai',
389
+ defaultModelId: 'gpt-5-mini',
390
+ providers: [
391
+ {
392
+ id: 'openai',
393
+ name: 'OpenAI',
394
+ models: [{ id: 'gpt-5-mini', name: 'GPT-5 mini' }],
395
+ },
396
+ ],
397
+ },
398
+ })
399
+ })
400
+
401
+ await waitFor(() => {
402
+ expect(screen.getByRole('button', { name: 'Select AI model' })).toBeInTheDocument()
258
403
  })
259
404
  })
260
405