Compare commits

...

2 Commits

Author SHA1 Message Date
Maurice 2d398568da 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 09:48:37 +01:00
Maurice 4051af58a7 fix(studio): a rail label wraps between words, never inside one
The rail gave its labels 48px and let them break anywhere, so Elements read Element and s, and nine languages broke a word at the default text size. Labels wrap only between words now, and the rail grows to its longest word from 64px.
2026-10-25 09:46:40 +01:00
17 changed files with 267 additions and 38 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 },
@@ -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 (
+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) {
+13 -13
View File
@@ -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); }