mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d398568da | |||
| 4051af58a7 |
@@ -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 },
|
||||
|
||||
@@ -19,7 +19,8 @@ import type { StudioUploader } from './studioUpload'
|
||||
* has a handful of completely different kinds of thing you reach for (which
|
||||
* page, what from the journey, what arrangement, what to draw), and putting
|
||||
* them behind tabs in a single 200px column would make each of them cramped.
|
||||
* An icon rail costs 64px and gives the panel the room a photo browser needs.
|
||||
* An icon rail costs 64px, a little more where a section's name holds a longer
|
||||
* word, and gives the panel the room a photo browser needs.
|
||||
*
|
||||
* Which section is open is the shell's when it says so (`section` and
|
||||
* `onSection`), so the inspector on the other side can open the Theme or the
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { BookDocument } from '@trek/shared';
|
||||
import { normalizeBookDocument } from '@trek/shared';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
@@ -9,11 +11,11 @@ import type { StudioUploader } from '../studioUpload';
|
||||
import { RAIL_SECTIONS, type RailSection } from './railSections';
|
||||
|
||||
/**
|
||||
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-006: the rail down the side of Studio
|
||||
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-007: the rail down the side of Studio
|
||||
* and the panel it opens. Every section is a tab that opens its own panel
|
||||
* under one head, the keyboard reaches every tab, a long name wraps inside
|
||||
* the rail, and the shell can hold the open section so the inspector can open
|
||||
* one from the other side.
|
||||
* the rail and only between words, and the shell can hold the open section so
|
||||
* the inspector can open one from the other side.
|
||||
*/
|
||||
|
||||
const SOURCE: JourneySource = { entries: [], photos: [], photoEntries: {} };
|
||||
@@ -54,6 +56,16 @@ function draw(over: { section?: RailSection; onSection?: (s: RailSection) => voi
|
||||
);
|
||||
}
|
||||
|
||||
/** Studio's stylesheet, which jsdom does not apply: its rules are the assertion. */
|
||||
const STUDIO_CSS = readFileSync(resolve(process.cwd(), 'src/styles/studio.css'), 'utf8');
|
||||
|
||||
/** The rule written for exactly this selector, searched in `source` (one media query, say). */
|
||||
function rule(selector: string, source = STUDIO_CSS): string {
|
||||
const at = source.indexOf(`${selector} {`);
|
||||
expect(at, `${selector} missing from studio.css`).toBeGreaterThan(-1);
|
||||
return source.slice(at, source.indexOf('}', at) + 1);
|
||||
}
|
||||
|
||||
/** The head of the panel that is open: the name every panel opens on. */
|
||||
const panelTitle = () => must(document.querySelector('.st-side .st-panel-head .st-what-label')).textContent;
|
||||
|
||||
@@ -168,4 +180,42 @@ describe('the rail', () => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.stickers' }));
|
||||
expect(screen.getByRole('textbox', { name: 'journey.studio.searchStickers' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-STUDIO-RAIL-007: wraps a name only between words, the rail growing to its longest word instead', () => {
|
||||
// "Elements" in the label's 9.5px Poppins is 50.4px wide and a 52px tab left
|
||||
// it 48px, so overflow-wrap: anywhere cut it into "Element" and "s" wherever
|
||||
// no hyphenation dictionary was at hand. Catalan's "Maquetacions" is 77px and
|
||||
// a larger text size widens every word, so no fixed width holds them all.
|
||||
draw();
|
||||
const selector = '.trek-dash .st-rail-btn .st-rail-label';
|
||||
const names = [...document.querySelectorAll('.st-rail-label')];
|
||||
expect(names).toHaveLength(RAIL_SECTIONS.length);
|
||||
// Every name the rail renders is one the rule reaches; .trek-dash is the shell's root.
|
||||
for (const name of names) expect(name.matches(selector.replace('.trek-dash ', ''))).toBe(true);
|
||||
const label = rule(selector);
|
||||
expect(label).toMatch(/overflow-wrap:\s*normal;/);
|
||||
expect(label).toMatch(/word-break:\s*keep-all;/);
|
||||
expect(label).toMatch(/hyphens:\s*manual;/);
|
||||
expect(label).not.toMatch(/anywhere|break-all|break-word|hyphens:\s*auto/);
|
||||
// The type stays on the caption step the user's text size scales.
|
||||
expect(label).toMatch(/font-size:\s*calc\([\d.]+px \* var\(--fs-scale-caption, 1\)\)/);
|
||||
|
||||
// A tab takes the rail's width, and the rail's column is 64px or its
|
||||
// min-content, which is the longest word, in each of the three layouts.
|
||||
expect(rule('.trek-dash .st-rail-btn')).not.toMatch(/[\s;{]width:/);
|
||||
const columns = [...STUDIO_CSS.matchAll(/\.st-body \{[^}]*?grid-template-columns:\s*([^;]+);/g)].map((m) => m[1]);
|
||||
expect(columns).toEqual([
|
||||
'minmax(64px, min-content) 236px minmax(0, 1fr) 300px',
|
||||
'minmax(64px, min-content) 236px minmax(0, 1fr)',
|
||||
'minmax(64px, min-content) minmax(0, 1fr)',
|
||||
]);
|
||||
|
||||
// Below 1080 the panel floats over the workbench's own cell, so it starts
|
||||
// where the rail ends, not at a fixed 64px from the edge.
|
||||
const at = STUDIO_CSS.indexOf('@media (max-width: 1079px) {');
|
||||
expect(at, 'the narrow layout missing from studio.css').toBeGreaterThan(-1);
|
||||
const narrow = STUDIO_CSS.slice(at, STUDIO_CSS.indexOf('\n}', at));
|
||||
expect(narrow).toContain('.trek-dash .st-side, .trek-dash .st-work-wrap { grid-area: 1 / 2; }');
|
||||
expect(rule('.trek-dash .st-side', narrow)).not.toMatch(/position:\s*absolute|inset-inline-start/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,9 @@ import { RAIL_IDS, RAIL_SECTIONS, type RailSection } from './railSections';
|
||||
* A list of tabs to a screen reader and to the keyboard, as it is to the eye:
|
||||
* Tab reaches the open section, the arrows move up and down the rail, Home and
|
||||
* End jump to its ends, and Enter or Space opens a section (studioRoving.ts).
|
||||
* A name longer than the rail is wide wraps onto a second line and breaks
|
||||
* inside a word only when the word alone is wider than the rail; the title
|
||||
* carries it whole either way.
|
||||
* A name longer than the rail is wide wraps onto a second line, only ever
|
||||
* between words: where one word alone is wider, the rail grows to fit it
|
||||
* (studio.css) instead of cutting it. The title carries the name whole.
|
||||
*/
|
||||
export function StudioRail({
|
||||
section,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -211,16 +211,17 @@
|
||||
.trek-dash .st-sep { width: 1px; height: 20px; background: var(--line-2); margin: 0 2px; flex: none; }
|
||||
|
||||
/* ----------------- body: rail | workbench | inspector ----------------- */
|
||||
/* The rail is 64px, or as wide as the longest word in a section's name. */
|
||||
.trek-dash .st-body {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 236px minmax(0, 1fr) 300px;
|
||||
grid-template-columns: minmax(64px, min-content) 236px minmax(0, 1fr) 300px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Under 1280 the inspector floats over the workbench instead of narrowing it —
|
||||
below roughly 700px of canvas a spread stops being legible at any zoom. */
|
||||
@media (max-width: 1279px) {
|
||||
.trek-dash .st-body { grid-template-columns: 64px 236px minmax(0, 1fr); }
|
||||
.trek-dash .st-body { grid-template-columns: minmax(64px, min-content) 236px minmax(0, 1fr); }
|
||||
.trek-dash .st-inspector {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
@@ -231,12 +232,11 @@
|
||||
}
|
||||
}
|
||||
@media (max-width: 1079px) {
|
||||
.trek-dash .st-body { grid-template-columns: 64px minmax(0, 1fr); }
|
||||
.trek-dash .st-body { grid-template-columns: minmax(64px, min-content) minmax(0, 1fr); }
|
||||
/* The panel floats over the workbench's cell, so it starts wherever the rail ends. */
|
||||
.trek-dash .st-side, .trek-dash .st-work-wrap { grid-area: 1 / 2; }
|
||||
.trek-dash .st-side {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
inset-inline-start: 64px;
|
||||
bottom: 0;
|
||||
justify-self: start;
|
||||
width: 236px;
|
||||
box-shadow: var(--sh-lg);
|
||||
}
|
||||
@@ -457,7 +457,6 @@
|
||||
}
|
||||
.trek-dash .st-rail-btn {
|
||||
flex: none;
|
||||
width: 52px;
|
||||
min-height: 52px;
|
||||
padding: 6px 2px;
|
||||
display: flex;
|
||||
@@ -469,9 +468,9 @@
|
||||
color: var(--ink-3);
|
||||
transition: background .16s, color .16s;
|
||||
}
|
||||
/* Two lines at most, broken inside a word that is longer than the rail is wide
|
||||
("Путешествие", "Tərtibatlar"); the button's title carries the whole name. */
|
||||
.trek-dash .st-rail-btn span {
|
||||
/* Wrapped between words, never inside one: the rail grows to the longest word
|
||||
("Maquetacions", "Путешествие"), and keep-all holds "コンテンツ" whole too. */
|
||||
.trek-dash .st-rail-btn .st-rail-label {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -481,8 +480,9 @@
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
overflow-wrap: normal;
|
||||
word-break: keep-all;
|
||||
hyphens: manual;
|
||||
}
|
||||
.trek-dash .st-rail-btn:focus { outline: none; }
|
||||
.trek-dash .st-rail-btn:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 3px var(--accent); }
|
||||
|
||||
Reference in New Issue
Block a user