Compare commits

..

1 Commits

Author SHA1 Message Date
Maurice 8438d5544b 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.
2026-10-25 00:11:30 +00:00
13 changed files with 196 additions and 18 deletions
+1 -1
View File
@@ -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"]
},
@@ -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);
@@ -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<WindowTrip | null>(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.
@@ -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));
});
});
@@ -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]);
}
@@ -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([]);
});
@@ -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({
@@ -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 },
@@ -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 (
@@ -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> = {}): 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 },
@@ -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 (
+45 -1
View File
@@ -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();
});
});
+25 -1
View File
@@ -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<AssistantState, 'byTrip'>, 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<ReturnType<typeof useAddonStore.getState>, '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<number, AssistantTripChat>): Record<number,
function shellActions(ctx: Ctx) {
return {
loadStatus: async () => {
// 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) {