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) {