From 8438d5544bf11c4fa7a9c2e4a23c4a0cc138e28d Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 25 Oct 2026 00:11:22 +0000 Subject: [PATCH] fix(assistant): no status request while neither AI addon is on Every page of an install with the assistant and AI parsing off asked /api/ai/status and logged the 404 the addon guard answers. The store asks only while an addon opens the route and the install is not managed, and one hook on the desktop window and both phone screens asks again when the assistant or AI parsing is switched. --- client/scripts/feature-pairs.json | 2 +- .../Admin/addons/useAiAdminConfig.test.ts | 4 + .../components/Assistant/AssistantWindow.tsx | 6 +- .../Assistant/useAssistantStatus.test.ts | 77 +++++++++++++++++++ .../Assistant/useAssistantStatus.ts | 22 ++++++ .../ai/AiConnectionSection.mapFollow.test.tsx | 3 + .../Settings/ai/AiConnectionSection.test.tsx | 3 + .../Settings/ai/useAiConnections.test.ts | 4 + .../assistant/MAssistantHomeScreen.tsx | 10 +-- .../settings/MAiConnectionSection.test.tsx | 3 + .../trip/assistant/MAssistantScreen.tsx | 8 +- client/src/store/assistantStore.test.ts | 46 ++++++++++- client/src/store/assistantStore.ts | 26 ++++++- 13 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 client/src/components/Assistant/useAssistantStatus.test.ts create mode 100644 client/src/components/Assistant/useAssistantStatus.ts diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index bcc35dc1b..3a7d5cd10 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -65,7 +65,7 @@ }, { "feature": "AI assistant chat", - "hooks": ["src/components/Assistant/useAssistantThread.ts"], + "hooks": ["src/components/Assistant/useAssistantThread.ts", "src/components/Assistant/useAssistantStatus.ts"], "desktop": ["src/components/Assistant/AssistantWindow.tsx"], "mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"] }, diff --git a/client/src/components/Admin/addons/useAiAdminConfig.test.ts b/client/src/components/Admin/addons/useAiAdminConfig.test.ts index 307802876..c8cc70393 100644 --- a/client/src/components/Admin/addons/useAiAdminConfig.test.ts +++ b/client/src/components/Admin/addons/useAiAdminConfig.test.ts @@ -5,7 +5,9 @@ import { MASKED_SETTING_VALUE } from '@trek/shared'; import { createElement, type ReactNode } from 'react'; import { MemoryRouter } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { seedStore } from '../../../../tests/helpers/store'; import { TranslationProvider } from '../../../i18n/TranslationContext'; +import { useAddonStore } from '../../../store/addonStore'; import { useAssistantStore } from '../../../store/assistantStore'; const repo = vi.hoisted(() => ({ @@ -59,6 +61,8 @@ async function renderLoaded() { beforeEach(() => { vi.clearAllMocks(); useAssistantStore.setState({ status: null }); + // The settings show while the assistant is on, and only then is its status asked for. + seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] }); repo.status.mockRejectedValue(new Error('not needed')); repo.adminSettings.mockResolvedValue(SETTINGS); repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next); diff --git a/client/src/components/Assistant/AssistantWindow.tsx b/client/src/components/Assistant/AssistantWindow.tsx index 18e4de54f..56bf39fd4 100644 --- a/client/src/components/Assistant/AssistantWindow.tsx +++ b/client/src/components/Assistant/AssistantWindow.tsx @@ -17,6 +17,7 @@ import type { AssistantChatTrip } from './assistantViewContext'; import type { EntityTarget } from './cards/cardTypes'; import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome'; import { useAssistantHotkey } from './useAssistantHotkey'; +import { useAssistantStatus } from './useAssistantStatus'; import { useAssistantThread } from './useAssistantThread'; /** @@ -202,15 +203,12 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap const available = useAssistantStore((s) => s.status?.available === true); const open = useAssistantStore((s) => s.window.open); const minimized = useAssistantStore((s) => s.window.minimized); - const loadStatus = useAssistantStore((s) => s.loadStatus); const toggle = useAssistantStore((s) => s.toggle); const [picked, setPicked] = useState(null); const [wasOpen, setWasOpen] = useState(open); const home = useAssistantHome(setPicked); - useEffect(() => { - void loadStatus(); - }, [loadStatus]); + useAssistantStatus(); useAssistantHotkey(available, toggle); // A trip picked outside the planner is only for this opening; the next one opens on the home chat again. diff --git a/client/src/components/Assistant/useAssistantStatus.test.ts b/client/src/components/Assistant/useAssistantStatus.test.ts new file mode 100644 index 000000000..2adb89b82 --- /dev/null +++ b/client/src/components/Assistant/useAssistantStatus.test.ts @@ -0,0 +1,77 @@ +/** + * FE-AI-STATUS-001 to FE-AI-STATUS-003 + * + * The views of the assistant ask for its status through this hook: not while + * both addons that open /api/ai are off, where the server answers 404, and + * as soon as one comes on, which the addon list can say after the view + * mounted (the first load, an admin's switch). The assistant's own addon + * switching asks again while AI parsing keeps /api/ai open. + */ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { AiStatusResponse } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { aiConnectionsRepo } from '../../repo/aiConnectionsRepo'; +import { useAddonStore } from '../../store/addonStore'; +import { useAssistantStore } from '../../store/assistantStore'; +import { useAssistantStatus } from './useAssistantStatus'; + +const INITIAL = useAssistantStore.getState(); +const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse; + +/** The addon list as it lands from the server, with these addons on. */ +const addonsOn = (...ids: string[]) => + seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) }); + +beforeEach(() => { + resetAllStores(); +}); + +afterEach(() => { + useAssistantStore.setState(INITIAL, true); + vi.restoreAllMocks(); +}); + +describe('useAssistantStatus', () => { + it('FE-AI-STATUS-001: asks nothing before the addon list or with both addons off, and asks once one comes on', async () => { + const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS); + renderHook(() => useAssistantStatus()); + act(() => addonsOn('mcp')); + expect(status).not.toHaveBeenCalled(); + expect(useAssistantStore.getState().status).toBeNull(); + + act(() => addonsOn('ai_assistant')); + await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS)); + expect(status).toHaveBeenCalledTimes(1); + + // The list read again with the assistant still on asks nothing more. + act(() => addonsOn('ai_assistant', 'mcp')); + expect(status).toHaveBeenCalledTimes(1); + }); + + it('FE-AI-STATUS-002: with AI parsing on it asks at once, and the last addon going off hides the assistant', async () => { + const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS); + addonsOn('llm_parsing'); + renderHook(() => useAssistantStatus()); + await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS)); + expect(status).toHaveBeenCalledTimes(1); + + act(() => addonsOn()); + expect(useAssistantStore.getState().status).toBeNull(); + expect(status).toHaveBeenCalledTimes(1); + }); + + it('FE-AI-STATUS-003: with AI parsing on, the assistant switched on or off asks again', async () => { + const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS); + addonsOn('llm_parsing'); + renderHook(() => useAssistantStatus()); + await waitFor(() => expect(status).toHaveBeenCalledTimes(1)); + + act(() => addonsOn('llm_parsing', 'ai_assistant')); + await waitFor(() => expect(status).toHaveBeenCalledTimes(2)); + + act(() => addonsOn('llm_parsing')); + await waitFor(() => expect(status).toHaveBeenCalledTimes(3)); + }); +}); diff --git a/client/src/components/Assistant/useAssistantStatus.ts b/client/src/components/Assistant/useAssistantStatus.ts new file mode 100644 index 000000000..b905919c3 --- /dev/null +++ b/client/src/components/Assistant/useAssistantStatus.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +import { useAddonStore } from '../../store/addonStore'; +import { aiRoutesOpen, useAssistantStore } from '../../store/assistantStore'; + +/** + * Asks for the assistant's status when a view of it mounts, and again when + * either addon that opens /api/ai is switched: the addon list can land after + * the view, and an admin can switch either addon while it is open. The + * assistant's own addon counts on its own, since the status says it runs only + * while that one is on, even with AI parsing keeping /api/ai open. The store + * asks the server only while one is on (aiRoutesOpen). + */ +export function useAssistantStatus(): void { + const loadStatus = useAssistantStore((s) => s.loadStatus); + const routesOpen = useAddonStore(aiRoutesOpen); + const assistantOn = useAddonStore((s) => s.isEnabled('ai_assistant')); + + useEffect(() => { + void loadStatus(); + }, [routesOpen, assistantOn, loadStatus]); +} diff --git a/client/src/components/Settings/ai/AiConnectionSection.mapFollow.test.tsx b/client/src/components/Settings/ai/AiConnectionSection.mapFollow.test.tsx index 5b36c47b2..db8d1ebfa 100644 --- a/client/src/components/Settings/ai/AiConnectionSection.mapFollow.test.tsx +++ b/client/src/components/Settings/ai/AiConnectionSection.mapFollow.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { buildUser } from '../../../../tests/helpers/factories'; import { render, screen } from '../../../../tests/helpers/render'; import { resetAllStores, seedStore } from '../../../../tests/helpers/store'; +import { useAddonStore } from '../../../store/addonStore'; import { useAssistantStore } from '../../../store/assistantStore'; import { useAuthStore } from '../../../store/authStore'; import { useSettingsStore } from '../../../store/settingsStore'; @@ -33,6 +34,8 @@ beforeEach(() => { vi.clearAllMocks(); useAssistantStore.setState({ status: null }); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); + // The card shows while the assistant is on, and only then is its status asked for. + seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] }); repo.models.mockResolvedValue([]); }); diff --git a/client/src/components/Settings/ai/AiConnectionSection.test.tsx b/client/src/components/Settings/ai/AiConnectionSection.test.tsx index 22e829ae8..f7c7fb0c0 100644 --- a/client/src/components/Settings/ai/AiConnectionSection.test.tsx +++ b/client/src/components/Settings/ai/AiConnectionSection.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { buildAdmin, buildUser } from '../../../../tests/helpers/factories'; import { render, screen, waitFor } from '../../../../tests/helpers/render'; import { resetAllStores, seedStore } from '../../../../tests/helpers/store'; +import { useAddonStore } from '../../../store/addonStore'; import { useAssistantStore } from '../../../store/assistantStore'; import { useAuthStore } from '../../../store/authStore'; @@ -51,6 +52,8 @@ beforeEach(() => { vi.clearAllMocks(); useAssistantStore.setState({ status: null }); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); + // The card shows while the assistant is on, and only then is its status asked for. + seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] }); repo.models.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]); repo.disconnect.mockResolvedValue(undefined); repo.chatgptStart.mockResolvedValue({ diff --git a/client/src/components/Settings/ai/useAiConnections.test.ts b/client/src/components/Settings/ai/useAiConnections.test.ts index 68b74244d..a8aeaff43 100644 --- a/client/src/components/Settings/ai/useAiConnections.test.ts +++ b/client/src/components/Settings/ai/useAiConnections.test.ts @@ -4,7 +4,9 @@ import type { AiConnectionView, AiStatusResponse } from '@trek/shared'; import { createElement, type ReactNode } from 'react'; import { MemoryRouter, useLocation } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { seedStore } from '../../../../tests/helpers/store'; import { TranslationProvider } from '../../../i18n/TranslationContext'; +import { useAddonStore } from '../../../store/addonStore'; import { useAssistantStore } from '../../../store/assistantStore'; const repo = vi.hoisted(() => ({ @@ -61,6 +63,8 @@ function renderConnections(phone = false) { beforeEach(() => { vi.clearAllMocks(); useAssistantStore.setState({ status: null, window: { open: false, minimized: false } }); + // The card shows while the assistant is on, and only then is its status asked for. + seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] }); repo.status.mockResolvedValue(status()); repo.models.mockResolvedValue([ { id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }, diff --git a/client/src/mobile/screens/assistant/MAssistantHomeScreen.tsx b/client/src/mobile/screens/assistant/MAssistantHomeScreen.tsx index 86bef6d86..65626f312 100644 --- a/client/src/mobile/screens/assistant/MAssistantHomeScreen.tsx +++ b/client/src/mobile/screens/assistant/MAssistantHomeScreen.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useMatch, useNavigate } from 'react-router'; import type { AssistantChatTrip } from '../../../components/Assistant/assistantViewContext'; import type { EntityTarget } from '../../../components/Assistant/cards/cardTypes'; import { chatPagePath, useAssistantHome, useRouteJournalChat } from '../../../components/Assistant/useAssistantHome'; +import { useAssistantStatus } from '../../../components/Assistant/useAssistantStatus'; import { useAssistantStore } from '../../../store/assistantStore'; import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScreen'; @@ -26,7 +27,6 @@ export default function MAssistantHomeScreen() { const available = useAssistantStore((s) => s.status?.available === true); const open = useAssistantStore((s) => s.window.open); const minimized = useAssistantStore((s) => s.window.minimized); - const loadStatus = useAssistantStore((s) => s.loadStatus); const close = useAssistantStore((s) => s.close); // The trip's screen (or the journal's) takes over with the window still open, so its chat comes up there. @@ -46,10 +46,8 @@ export default function MAssistantHomeScreen() { [close, navigate] ); - // The desktop window loads this itself; on the phone the pages outside a trip ask once. - useEffect(() => { - void loadStatus(); - }, [loadStatus]); + // The desktop window loads this itself; on the phone the pages outside a trip ask here. + useAssistantStatus(); if (inTrip || !available || !open || minimized) return null; return ( diff --git a/client/src/mobile/screens/settings/MAiConnectionSection.test.tsx b/client/src/mobile/screens/settings/MAiConnectionSection.test.tsx index e9a1e0281..5d0e06718 100644 --- a/client/src/mobile/screens/settings/MAiConnectionSection.test.tsx +++ b/client/src/mobile/screens/settings/MAiConnectionSection.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { buildUser } from '../../../../tests/helpers/factories'; import { render, screen, waitFor } from '../../../../tests/helpers/render'; import { seedStore } from '../../../../tests/helpers/store'; +import { useAddonStore } from '../../../store/addonStore'; import { useAssistantStore } from '../../../store/assistantStore'; import { useAuthStore } from '../../../store/authStore'; @@ -47,6 +48,8 @@ function status(over: Partial = {}): AiStatusResponse { beforeEach(() => { vi.clearAllMocks(); useAssistantStore.setState({ status: null }); + // The card shows while the assistant is on, and only then is its status asked for. + seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] }); repo.models.mockResolvedValue([ { id: 'gemini-flash', label: 'Gemini Flash', supportsTools: true, free: true }, { id: 'gemini-pro', label: 'Gemini Pro', supportsTools: true }, diff --git a/client/src/mobile/screens/trip/assistant/MAssistantScreen.tsx b/client/src/mobile/screens/trip/assistant/MAssistantScreen.tsx index 080513f8f..a07db12ca 100644 --- a/client/src/mobile/screens/trip/assistant/MAssistantScreen.tsx +++ b/client/src/mobile/screens/trip/assistant/MAssistantScreen.tsx @@ -23,6 +23,7 @@ import RoutePeekLazy from '../../../../components/Assistant/map/RoutePeekLazy'; import { moveRoutePeek } from '../../../../components/Assistant/map/routePeekControl'; import { useAssistantHintsSetting } from '../../../../components/Assistant/useAssistantHints'; import { useAssistantLiveStep } from '../../../../components/Assistant/useAssistantLiveStep'; +import { useAssistantStatus } from '../../../../components/Assistant/useAssistantStatus'; import { useAssistantThread } from '../../../../components/Assistant/useAssistantThread'; import { useTripMemory } from '../../../../components/Assistant/useTripMemory'; import { useKeyboardInset } from '../../../../hooks/useKeyboardInset'; @@ -395,13 +396,10 @@ export default function MAssistantScreen({ planner, shell }: MTripSheetsProps) { const minimized = useAssistantStore((s) => s.window.minimized); const peeking = useAssistantStore((s) => s.routePeek?.tripId === planner.tripId); const running = useAssistantStore((s) => s.byTrip[planner.tripId]?.isRunning === true); - const loadStatus = useAssistantStore((s) => s.loadStatus); const hops = useMapHops({ planner, shell }); - // The desktop window loads this itself; on the phone the trip screen asks once. - useEffect(() => { - void loadStatus(); - }, [loadStatus]); + // The desktop window loads this itself; on the phone the trip screen asks here. + useAssistantStatus(); if (!available || !open || !planner.trip) return null; return ( diff --git a/client/src/store/assistantStore.test.ts b/client/src/store/assistantStore.test.ts index 4f1f1307a..e8f46fe6e 100644 --- a/client/src/store/assistantStore.test.ts +++ b/client/src/store/assistantStore.test.ts @@ -1,5 +1,5 @@ /** - * FE-AI-STORE-001 to FE-AI-STORE-012 + * FE-AI-STORE-001 to FE-AI-STORE-014 * * The store runs a turn end to end against a faked repo: the request it * sends, how the stream lands in the chat, and what each way of failing @@ -9,11 +9,14 @@ */ import type { AiStatusResponse, AssistantMessage, AssistantThreadResponse, UiStreamChunk } from '@trek/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { seedStore } from '../../tests/helpers/store'; import { AssistantRequestError } from '../api/assistant'; import { aiConnectionsRepo } from '../repo/aiConnectionsRepo'; import { assistantRepo } from '../repo/assistantRepo'; import { OnlineOnlyError } from '../repo/onlineOnly'; +import { useAddonStore } from './addonStore'; import { chatOf, useAssistantStore } from './assistantStore'; +import { useAuthStore } from './authStore'; import { useSettingsStore } from './settingsStore'; const TRIP = 4; @@ -60,6 +63,10 @@ async function* hangingStream(first: UiStreamChunk[], signal: AbortSignal): Asyn }); } +/** The addon list as it lands from the server, with these addons on. */ +const addonsOn = (...ids: string[]) => + seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) }); + const chat = () => chatOf(useAssistantStore.getState(), TRIP); const store = () => useAssistantStore.getState(); @@ -76,6 +83,7 @@ beforeEach(() => { lastApplied: null, }); useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: 'de' } })); + useAuthStore.setState({ managed: false }); }); afterEach(() => vi.restoreAllMocks()); @@ -311,6 +319,7 @@ describe('assistantStore: threads, window and status', () => { }); it('FE-AI-STORE-012: status fails closed, offline keeps the last answer, and the privacy notice is acknowledged', async () => { + addonsOn('ai_assistant'); vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValueOnce(STATUS); await store().loadStatus(); expect(store().status).toEqual(STATUS); @@ -328,4 +337,39 @@ describe('assistantStore: threads, window and status', () => { await store().loadStatus(); expect(store().status).toBeNull(); }); + + it('FE-AI-STORE-013: the status is asked for only while the assistant or AI parsing is on', async () => { + const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS); + useAddonStore.setState({ addons: [], loaded: false }); + await store().loadStatus(); + addonsOn('mcp'); + await store().loadStatus(); + expect(status).not.toHaveBeenCalled(); + expect(store().status).toBeNull(); + + addonsOn('llm_parsing'); + await store().loadStatus(); + expect(status).toHaveBeenCalledTimes(1); + expect(store().status).toEqual(STATUS); + + addonsOn('ai_assistant'); + await store().loadStatus(); + expect(status).toHaveBeenCalledTimes(2); + + // Switched off again, the assistant goes away without a request. + addonsOn(); + await store().loadStatus(); + expect(status).toHaveBeenCalledTimes(2); + expect(store().status).toBeNull(); + }); + + it('FE-AI-STORE-014: a managed install, which refuses /api/ai, is not asked whatever its addons say', async () => { + const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS); + useAssistantStore.setState({ status: STATUS }); + useAuthStore.setState({ managed: true }); + addonsOn('ai_assistant', 'llm_parsing'); + await store().loadStatus(); + expect(status).not.toHaveBeenCalled(); + expect(store().status).toBeNull(); + }); }); diff --git a/client/src/store/assistantStore.ts b/client/src/store/assistantStore.ts index ffe349e8d..3c0156f92 100644 --- a/client/src/store/assistantStore.ts +++ b/client/src/store/assistantStore.ts @@ -25,6 +25,7 @@ import type { Assignment } from '../types'; import { browserTimeZone } from '../utils/localDate'; import { randomId } from '../utils/randomId'; import type { RouteProfileFocus } from '../utils/routeGeometry'; +import { useAddonStore } from './addonStore'; import { assistantScopeOf, assistantTargetOf, type AssistantChatKey } from './assistantChatKey'; import { blockDays, @@ -36,6 +37,7 @@ import { type WindowRect, } from './assistantMapModel'; import { applyChunk, closeUndo, markDecisions } from './assistantMessages'; +import { useAuthStore } from './authStore'; import { useSettingsStore } from './settingsStore'; import { useTripStore } from './tripStore'; @@ -120,7 +122,10 @@ interface UndoBlockResult { } interface AssistantState { - /** What the assistant runs on for this user; null until loaded, and the assistant stays hidden until then. */ + /** + * What the assistant runs on for this user; null until loaded, while no addon + * opens /api/ai (aiRoutesOpen) and on a managed install, and the assistant stays hidden then. + */ status: AiStatusResponse | null; window: { open: boolean; minimized: boolean }; /** The chats by `AssistantChatKey`: a trip's id, or a journey's key. */ @@ -316,6 +321,19 @@ export function chatOf(state: Pick, key: AssistantChat return state.byTrip[key] ?? EMPTY_CHAT; } +/** + * Whether the addons open /api/ai: the server opens it while the assistant or + * AI parsing is on, the two sharing one connection, and answers 404 while + * both are off. Closed until the addon list is in. A selector on the addon + * store; a managed install refuses /api/ai whatever it says (loadStatus). + */ +export function aiRoutesOpen({ + loaded, + isEnabled, +}: Pick, 'loaded' | 'isEnabled'>): boolean { + return loaded && (isEnabled('ai_assistant') || isEnabled('llm_parsing')); +} + function httpStatus(err: unknown): number | undefined { return (err as { response?: { status?: number } } | null)?.response?.status; } @@ -568,6 +586,12 @@ function backToAfter(byTrip: Record): Record { + // While /api/ai is closed the request could only get a 404, or the 403 of a managed install: nothing is asked, + // and the assistant stays hidden. The managed flag comes with the app config, which can land after the addons. + if (useAuthStore.getState().managed || !aiRoutesOpen(useAddonStore.getState())) { + ctx.set({ status: null }); + return; + } try { ctx.set({ status: await aiConnectionsRepo.status() }); } catch (err) {