feat(assistant): one AI tile for assistant, parsing and MCP, a planner-aware chat

The three AI addons share one tile in the integration column; a dialog holds
the connection, the assistant's limits and AI parsing, which now reads with
the assistant's connection unless an install keeps its own. Existing parsing
configs stay where they are and keep winning.

The chat sees the map on screen, offers next questions under an answer,
shows its live step in the window head, previews waiting changes inside the
day list, checks whether a day still works, folds a multi-day plan into a
week grid, keeps per-trip notes and can estimate costs. An empty or cut-off
answer gets one push instead of ending silently, and the assistant's own
step and size limits are raised.
This commit is contained in:
Maurice
2026-10-21 20:55:06 +02:00
parent b64da26651
commit 123e27643d
142 changed files with 5456 additions and 1572 deletions
+49 -278
View File
@@ -1,6 +1,6 @@
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
@@ -24,23 +24,20 @@ function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
}
function llmAddon(config: Record<string, unknown> = {}) {
function llmAddon(config: Record<string, unknown> = {}, enabled = true) {
return buildAddon({
id: 'llm_parsing',
name: 'AI Parsing',
description: 'Extract bookings from files',
icon: 'Sparkles',
type: 'integration',
enabled: true,
enabled,
config,
});
}
function modelsRoute(names: string[], seen?: (string | null)[]) {
return http.get('/api/admin/llm/local/models', ({ request }) => {
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
});
function aiAddon(id: string, name: string, enabled: boolean) {
return buildAddon({ id, name, icon: id === 'mcp' ? 'Terminal' : 'Sparkles', type: 'integration', enabled });
}
/** Every switch is labelled with the thing it controls, top-level tile or shelf
@@ -576,286 +573,55 @@ describe('AddonManager', () => {
expect(links.getAttribute('title')).not.toBe('No shared links yet');
});
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
render(<AddonManager />);
await screen.findByText('AI Parsing');
// The catalog key now exists, so the tile shows the translation rather than falling
// back to the English description the server sent.
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
render(<AddonManager />);
const label = await screen.findByText('Provider');
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
const tile = label.closest('article');
expect(tile).not.toBeNull();
expect(tile!.textContent).toContain('AI Parsing');
});
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
await screen.findByRole('button', { name: 'llama3:8b' });
expect(urls[0]).toBe('http://localhost:11434/v1');
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
const user = userEvent.setup();
let calls = 0;
it('FE-ADMIN-ADDON-020: the AI addons head the integration column as one tile with a switch each', async () => {
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1
? HttpResponse.json({ error: 'down' }, { status: 500 })
: HttpResponse.json({ models: [] });
}),
addonsRoute([
aiAddon('mcp', 'MCP', false),
llmAddon({}, false),
aiAddon('ai_assistant', 'AI Assistant', false),
buildAddon({ id: 'airtrail', name: 'AirTrail', type: 'integration', icon: 'Plane' }),
]),
);
render(<AddonManager />);
// The server's reason is shown, not axios' status line (#2649)
await screen.findByText('down');
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet — pull one below.');
expect(calls).toBe(2);
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
const switches = within(tile).getAllByRole('button').map((b) => b.getAttribute('aria-label'));
// In the tile's order, whatever order the server sent them in.
expect(switches).toEqual(['AI Assistant', 'AI Parsing', 'MCP']);
expect(within(tile).getByText('0/3')).toBeInTheDocument();
// Their own icons, keyed by id: the icon column of an install still says Sparkles or Terminal.
expect(tile.querySelector('svg.lucide-messages-square')).toBeInTheDocument();
expect(tile.querySelector('svg.lucide-scan-text')).toBeInTheDocument();
expect(tile.querySelector('svg.lucide-cable')).toBeInTheDocument();
// Nothing to set up while neither the assistant nor parsing is on.
expect(within(tile).queryByRole('button', { name: 'Set up AI' })).toBeNull();
// AirTrail is still its own tile in the same column.
expect(screen.getByText('AirTrail').closest('article')).not.toBe(tile);
});
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
it('FE-ADMIN-ADDON-021: with parsing on, the tile offers the settings dialog, which closes again', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
server.use(
addonsRoute([llmAddon({}), aiAddon('ai_assistant', 'AI Assistant', false), aiAddon('mcp', 'MCP', true)]),
http.get('/api/admin/ai/settings', () =>
HttpResponse.json({ policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }),
),
http.get('/api/admin/ai/connection', () => HttpResponse.json({ connection: null, prefill: null })),
http.get('/api/ai/status', () => HttpResponse.json({ error: 'not needed' }, { status: 404 })),
);
render(<AddonManager />);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
// A hand-typed base URL is used for the next lookup on blur
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
await user.tab();
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
// A gateway can stand in for Anthropic too, so the field stays (#2655).
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
expect(within(tile).getByText('2/3')).toBeInTheDocument();
await user.click(within(tile).getByRole('button', { name: 'Set up AI' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
expect(within(dialog).queryByRole('button', { name: 'AI Assistant' })).toBeNull();
const closes = within(dialog).getAllByRole('button', { name: 'Close' });
await user.click(closes[closes.length - 1]);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
const user = userEvent.setup();
let pulled: unknown = null;
let modelCalls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
await delay(150);
return new HttpResponse(
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
);
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('Pulling…');
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('no disk space');
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
expect(bodies[0]).toEqual({
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
});
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Failed to save');
});
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
// The model is trimmed before it is stored, the key is sent verbatim
expect(bodies[0]).toEqual({
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', vision: 'auto' },
});
});
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
expect(bodies[0]).toEqual({
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
});
});
it('FE-ADMIN-ADDON-038: whether the model reads images is auto, on or off, kept as stored and saved as chosen', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'off' })]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
// Stored off stays off; a cloud provider is told what Automatic means for it.
await user.click(await screen.findByRole('button', { name: 'No' }));
expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Yes' }));
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
expect(bodies[0]).toMatchObject({ config: { provider: 'openai', vision: 'on' } });
expect(bodies[0]).not.toHaveProperty('config.multimodal');
});
it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => {
server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([]));
render(<AddonManager />);
expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
)),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('manifest not found');
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
render(<AddonManager />);
await screen.findByText('Provider');
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
await user.tab();
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
expect(urls).toHaveLength(0);
});
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
@@ -901,6 +667,10 @@ describe('AddonManager', () => {
</>
);
// Folded like Collab's and Documents' options until the bar is opened.
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
await unfoldShelves();
const toggle = await screen.findByRole('button', { name: 'wanderer' });
expect(isOn(toggle)).toBe(false);
await userEvent.click(toggle);
@@ -921,6 +691,7 @@ describe('AddonManager', () => {
</>
);
await unfoldShelves();
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
+34 -179
View File
@@ -1,9 +1,7 @@
import { useId, type ComponentType } from 'react'
import type { ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
@@ -12,12 +10,10 @@ import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel'
import { useAddonManager } from './addons/useAddonManager'
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
import AiAdminConfig from './addons/AiAdminConfig'
import AiAddonTile from './addons/AiAddonTile'
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
import WandererMark from '../Tours/wanderer/WandererMark'
@@ -75,7 +71,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const { t } = useTranslation()
const dark = useIsDark()
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
mapSwitches,
@@ -155,37 +151,29 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
})
}
// An array, not a fragment: the tile counts its rows to fold them behind the
// "N options" bar the way Collab's and Documents' are.
if (addon.id === 'tours' && addon.enabled) {
return (
<>
return [
<AddonSubRow
key="wanderer"
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>,
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
key={key}
icon={<Icon size={14} />}
title={t(titleKey)}
description={t(subtitleKey)}
enabled={enabled}
onToggle={() => void toggle()}
/>
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
key={key}
icon={<Icon size={14} />}
title={t(titleKey)}
description={t(subtitleKey)}
enabled={enabled}
onToggle={() => void toggle()}
/>
))}
</>
)
}
// The AI-parsing settings hang off their tile like every other sub-feature,
// just as a form instead of toggle rows.
if (addon.id === 'llm_parsing' && addon.enabled) {
return <LlmParsingConfig addon={addon} />
}
// The assistant's settings and the connection everyone runs on, the same way.
if (addon.id === 'ai_assistant' && addon.enabled) {
return <AiAdminConfig />
)),
]
}
return null
}
@@ -214,9 +202,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{ key: 'trip', addons: tripAddons },
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter(g => g.addons.length > 0)
].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0))
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + aiAddons.length
const subtitle = (
<span className="inline-flex flex-wrap items-center gap-1">
@@ -247,8 +235,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const meta = TYPE_META[g.key]
return (
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
<div className="divide-y divide-edge-faint">
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{g.addons.map(tile)}
</div>
</section>
)
})}
@@ -287,140 +279,3 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
</div>
)
}
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
* overrides per-user config (see server llmConfig.ts). The API key is masked on
* read; an unchanged mask is treated as a no-op by the server. For the local
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const { t } = useTranslation()
const {
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
} = useLlmParsingConfig(addon)
const fieldId = useId()
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
/* Lives in the tile's shelf like the collab toggles, so it is one column of
eyebrow fields: the band this used to be had a whole page width. */
return (
<li className={`flex flex-col gap-3 p-3 ${saving ? 'opacity-60' : ''}`}>
<SettingsHint>
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
</SettingsHint>
<EditorField label="Provider">
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</EditorField>
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
</EditorField>
<EditorField
label="API key"
htmlFor={`${fieldId}-key`}
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
>
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</EditorField>
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</EditorField>
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
<Segmented<LlmVision>
label={t('settings.aiParsing.multimodal')}
value={vision}
onChange={v => setVision(asLlmVision(v))}
options={visionOptions}
fill
/>
</EditorField>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
<div className="flex items-center gap-2">
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
<button type="button" onClick={loadModels} disabled={loadingModels}
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
style={fs(11.5, 'body')}>
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button type="button"
key={name}
title={name}
onClick={() => setModel(name)}
aria-pressed={model === name}
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
style={fs(11.5)}
>
{name}
</button>
))}
</div>
)}
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className="min-w-0" title={m.note}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
{installedHere ? (
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
{isPulling && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
)
})}
</div>
</div>
)}
<div className="flex justify-end">
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</li>
)
}
@@ -0,0 +1,95 @@
import { ChevronRight, Settings2, Sparkles } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { StatusPill } from '../../Settings/settingsKit';
import { fs } from '../../shared/DialogShell';
import AddonSubRow from '../AddonSubRow';
import { type Addon, getAddonLabel } from './addonModel';
import { AI_ADDON_ICONS, type AiAddonId } from './aiAddons';
import AiSettingsDialog from './AiAdminConfig';
import { useAiAddonTile } from './useAiAddonTile';
/**
* The AI addons as one tile at the head of the integration column: the
* assistant, AI parsing and MCP as switches in its shelf, and one button to
* the dialog that holds their settings, so a page with all three on stays as
* short as one with none.
*/
export default function AiAddonTile({
addons,
onToggle,
}: {
addons: Addon[];
onToggle: (addon: Addon) => void;
}): React.ReactElement {
const { t } = useTranslation();
const tile = useAiAddonTile(addons);
const anyOn = tile.enabledCount > 0;
return (
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
<div className="flex items-start gap-3">
<div
className={`grid h-9 w-9 flex-none place-items-center rounded-[10px] transition-colors ${
anyOn
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
: 'bg-surface-tertiary text-content-faint'
}`}
>
<Sparkles size={18} />
</div>
<div className="min-w-0 flex-1 pt-px">
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
{t('admin.addons.ai.name')}
</h4>
<p
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${anyOn ? 'text-content-muted' : 'text-content-faint'}`}
style={fs(11.5)}
>
{t('admin.addons.ai.description')}
</p>
</div>
<div className="flex-none pt-1">
<StatusPill tone={anyOn ? 'success' : 'neutral'}>
{tile.enabledCount}/{addons.length}
</StatusPill>
</div>
</div>
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
{addons.map((addon) => {
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
const label = getAddonLabel(t, addon);
return (
<AddonSubRow
key={addon.id}
icon={<Icon size={14} />}
title={label.name}
description={label.description}
enabled={addon.enabled}
onToggle={() => onToggle(addon)}
/>
);
})}
</ul>
{tile.canSetUp && (
<button
type="button"
onClick={tile.openDialog}
className="flex min-h-[44px] w-full items-center gap-2.5 border-t border-edge-faint px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
>
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
<Settings2 size={14} />
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.addons.ai.setup')}
</span>
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
</button>
)}
</div>
{tile.dialogOpen && <AiSettingsDialog addons={addons} onClose={tile.closeDialog} />}
</article>
);
}
@@ -1,9 +1,13 @@
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-004: the AI assistant's form on its addon tile.
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-020: the AI settings dialog behind the AI tile.
import userEvent from '@testing-library/user-event';
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
import { delay, http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
import { useAssistantStore } from '../../../store/assistantStore';
import { ToastContainer } from '../../shared/Toast';
import type { Addon } from './addonModel';
const repo = vi.hoisted(() => ({
status: vi.fn(),
@@ -16,7 +20,7 @@ const repo = vi.hoisted(() => ({
}));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
const { default: AiAdminConfig } = await import('./AiAdminConfig');
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
const LIVE: AiAdminConnectionResponse = {
@@ -32,12 +36,44 @@ const LIVE: AiAdminConnectionResponse = {
prefill: null,
};
function renderConfig() {
return render(
<ul>
<AiAdminConfig />
</ul>
function addon(id: string, enabled: boolean, config?: Record<string, unknown>): Addon {
return { id, name: id, description: '', icon: 'Sparkles', type: 'integration', enabled, config } as Addon;
}
const ASSISTANT = addon('ai_assistant', true);
const parsing = (config: Record<string, unknown> = {}) => addon('llm_parsing', true, config);
function renderDialog(addons: Addon[] = [ASSISTANT]) {
const onClose = vi.fn();
render(
<>
<ToastContainer />
<AiSettingsDialog addons={addons} onClose={onClose} />
</>
);
return { onClose };
}
/** The dialog opened on AI parsing's tab. */
async function renderParsing(config: Record<string, unknown> = {}) {
const user = userEvent.setup();
renderDialog([ASSISTANT, parsing(config)]);
await user.click(await screen.findByRole('button', { name: 'AI Parsing' }));
return user;
}
function modelsRoute(names: string[], seen?: (string | null)[]) {
return http.get('/api/admin/llm/local/models', ({ request }) => {
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
});
}
function saveRoute(bodies: unknown[], fail = false) {
return http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return fail ? HttpResponse.error() : HttpResponse.json({ success: true });
});
}
beforeEach(() => {
@@ -51,32 +87,35 @@ beforeEach(() => {
repo.adminModels.mockResolvedValue([{ id: 'gpt-5.5', label: 'GPT-5.5', supportsTools: true }]);
});
describe('AiAdminConfig', () => {
it('FE-COMP-AI-ADMIN-001: shows who pays, the setup for everyone, the daily limit and the retention', async () => {
renderConfig();
describe('the connection and the assistant', () => {
it('FE-COMP-AI-ADMIN-001: shows who pays and the setup for everyone, the limits on the assistant tab', async () => {
const user = userEvent.setup();
renderDialog();
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
expect(screen.queryByText('Sign in with ChatGPT')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
expect(screen.getByText('Keep chats')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
const user = userEvent.setup();
renderConfig();
renderDialog();
await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' }));
await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument());
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => {
const user = userEvent.setup();
repo.adminConnection.mockResolvedValue(LIVE);
renderConfig();
renderDialog();
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('…9f3c')).toBeInTheDocument();
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
@@ -89,7 +128,7 @@ describe('AiAdminConfig', () => {
it('FE-COMP-AI-ADMIN-004: removing the connection asks first', async () => {
const user = userEvent.setup();
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue({ connection: null, prefill: null });
renderConfig();
renderDialog();
await user.click(await screen.findByRole('button', { name: 'Remove connection' }));
expect(screen.getByText('Disconnect OpenAI? Your chats stay.')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Remove connection' });
@@ -97,4 +136,201 @@ describe('AiAdminConfig', () => {
expect(await screen.findByText('No connection yet.')).toBeInTheDocument();
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
});
it('FE-COMP-AI-ADMIN-005: a tab shows only while its addon is on, and Close closes', async () => {
const user = userEvent.setup();
const { onClose } = renderDialog([addon('ai_assistant', false), parsing()]);
expect(await screen.findByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'AI Assistant' })).not.toBeInTheDocument();
const closes = screen.getAllByRole('button', { name: 'Close' });
await user.click(closes[closes.length - 1]);
expect(onClose).toHaveBeenCalled();
});
});
describe('AI parsing', () => {
it('FE-COMP-AI-ADMIN-010: without its own connection parsing reads with the shared one, and can be given its own', async () => {
repo.adminConnection.mockResolvedValue(LIVE);
server.use(modelsRoute([]));
const user = await renderParsing({ vision: 'auto' });
expect(await screen.findByText('Shared')).toBeInTheDocument();
expect(screen.getByText('gpt-5.5')).toBeInTheDocument();
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Use a different one' }));
expect(screen.getByLabelText('Model')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-011: with no connection anywhere it says so and leads to the connection tab', async () => {
const user = await renderParsing();
expect(
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Connection' }));
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-012: an own connection is shown as such and goes back to the shared one, keeping the vision setting', async () => {
const bodies: unknown[] = [];
repo.adminConnection.mockResolvedValue(LIVE);
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'anthropic', model: 'claude-x', apiKey: '••••••••', vision: 'on' });
expect(await screen.findByText('Own connection', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByText('claude-x')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Use the shared connection' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({ config: { vision: 'on' } });
expect(await screen.findByText('Shared')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-013: the local provider lists installed models and a chip fills the model field', async () => {
const urls: (string | null)[] = [];
server.use(modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
const user = await renderParsing({ provider: 'local', model: 'old' });
await screen.findByText('Installed on the server');
await user.click(await screen.findByRole('button', { name: 'llama3:8b' }));
expect(urls[0]).toBe('http://localhost:11434/v1');
expect(screen.getByLabelText('Model')).toHaveValue('llama3:8b');
// qwen3.5:4b is installed already, so the recommended row offers Use instead of Pull.
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
it("FE-COMP-AI-ADMIN-014: an unreachable Ollama shows the server's reason and Refresh retries", async () => {
let calls = 0;
server.use(
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
})
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('down');
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet. Pull one below.');
expect(calls).toBe(2);
});
it('FE-COMP-AI-ADMIN-015: switching providers swaps the address placeholder and the Ollama block, and drops a stale address', async () => {
const bodies: unknown[] = [];
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'local', model: 'm', baseUrl: 'http://ollama.lan:11434/v1' });
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toHaveValue('');
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: 'Anthropic' }));
// A gateway can stand in for Anthropic too, so the field stays (#2655).
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
await user.clear(screen.getByLabelText('Model'));
await user.type(screen.getByLabelText('Model'), ' claude-haiku ');
await user.type(screen.getByLabelText('API key'), 'sk-ant-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('AI settings saved');
// The stale local address must not ride along to Anthropic, and the model is trimmed.
expect(bodies[0]).toEqual({
config: { provider: 'anthropic', model: 'claude-haiku', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
});
});
it('FE-COMP-AI-ADMIN-016: a hand-typed address is asked on blur, a cloud provider asks nothing', async () => {
const urls: (string | null)[] = [];
server.use(modelsRoute([], urls));
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('Installed on the server');
await user.type(screen.getByLabelText('Base URL'), 'http://ollama.lan:11434/v1');
await user.tab();
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
const before = urls.length;
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
await user.type(screen.getByLabelText('Base URL'), 'https://proxy.local/v1');
await user.tab();
expect(urls).toHaveLength(before);
});
it('FE-COMP-AI-ADMIN-017: a pull streams progress and then selects the model', async () => {
let pulled: unknown = null;
let modelCalls = 0;
server.use(
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
await delay(150);
return new HttpResponse(
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } }
);
})
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('No models installed yet. Pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
expect(await screen.findAllByText('Pulling…')).not.toHaveLength(0);
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
it('FE-COMP-AI-ADMIN-018: an error frame or a refused pull is reported and the button comes back', async () => {
server.use(
modelsRoute([]),
http.post(
'/api/admin/llm/local/pull',
() =>
new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', {
headers: { 'Content-Type': 'application/x-ndjson' },
})
)
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('No models installed yet. Pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('manifest not found');
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
});
it('FE-COMP-AI-ADMIN-019: saving keeps a masked key and reports both outcomes', async () => {
const bodies: unknown[] = [];
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••' });
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
});
server.use(saveRoute(bodies, true));
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Could not save AI settings');
});
it('FE-COMP-AI-ADMIN-020: whether the model reads images saves at once, with the connection as saved', async () => {
const bodies: unknown[] = [];
server.use(saveRoute(bodies));
const user = await renderParsing({
provider: 'openai',
model: 'gpt-4.1-mini',
baseUrl: '',
apiKey: 'sk',
vision: 'off',
});
expect(await screen.findByRole('button', { name: 'No' })).toBeInTheDocument();
expect(
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Yes' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({
config: { provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'on' },
});
});
});
@@ -1,14 +1,25 @@
import { Check, Loader2 } from 'lucide-react';
import React, { useId } from 'react';
import { Check, Loader2, Sparkles } from 'lucide-react';
import React, { useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import ConfirmDialog from '../../shared/ConfirmDialog';
import CustomSelect from '../../shared/CustomSelect';
import { fs } from '../../shared/DialogShell';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
import { NumericInput } from '../../shared/NumericInput';
import { EditorField, INPUT } from '../../shared/dialogParts';
import { EditorField, INPUT, Segmented } from '../../shared/dialogParts';
import ParsingSettings from './ParsingSettings';
import type { Addon } from './addonModel';
import { POLICY_CHOICES, useAiAdminConfig } from './useAiAdminConfig';
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
@@ -125,28 +136,12 @@ function InstanceConnection({ A }: { A: AiAdmin }) {
);
}
/**
* The AI assistant's settings on its addon tile, as a form in the shelf like
* AI parsing's: who pays, the connection for everyone, the daily limit while
* the instance pays, and how long chats are kept. Each change saves at once.
*/
export default function AiAdminConfig(): React.ReactElement {
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
function AssistantLimits({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const A = useAiAdminConfig();
const fieldId = useId();
if (!A.loaded) {
return (
<li className="grid place-items-center p-4">
<Loader2 size={18} className="animate-spin text-content-faint" />
</li>
);
}
return (
<li className="flex flex-col gap-4 p-3">
<PolicyChoice A={A} />
{A.instancePays && <InstanceConnection A={A} />}
<div className="flex flex-col gap-4">
{A.instancePays && (
<EditorField
label={t('assistant.admin.dailyLimit')}
@@ -175,6 +170,91 @@ export default function AiAdminConfig(): React.ReactElement {
size="sm"
/>
</EditorField>
</div>
);
}
type AiTab = 'connection' | 'assistant' | 'parsing';
/**
* The AI settings behind the AI tile's button, in one dialog so the addon page
* stays short: the connection the assistant and AI parsing share (and who pays
* for it), the assistant's limits, and how bookings are read. A tab shows only
* while its addon is on. Each change saves at once.
*/
export default function AiSettingsDialog({
addons,
onClose,
}: {
addons: Addon[];
onClose: () => void;
}): React.ReactElement {
const { t } = useTranslation();
const labelId = useId();
const A = useAiAdminConfig();
const assistantOn = addons.some((a) => a.id === 'ai_assistant' && a.enabled);
const parsing = addons.find((a) => a.id === 'llm_parsing' && a.enabled) ?? null;
const tabs: { value: AiTab; label: string }[] = [
{ value: 'connection', label: t('admin.addons.ai.tab.connection') },
...(assistantOn ? [{ value: 'assistant' as const, label: t('admin.addons.catalog.ai_assistant.name') }] : []),
...(parsing ? [{ value: 'parsing' as const, label: t('admin.addons.catalog.llm_parsing.name') }] : []),
];
const [tab, setTab] = useState<AiTab>('connection');
const shown = tabs.some((x) => x.value === tab) ? tab : 'connection';
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="detail"
align="top"
blocked={A.instance.confirmingRemove}
header={
<DialogHeader
tile={
<DialogTile>
<Sparkles size={20} strokeWidth={2} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
eyebrow={t('admin.addons.ai.name')}
title={t('admin.addons.ai.setup')}
sub={t('admin.addons.ai.dialogSub')}
subWraps
onClose={onClose}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={onClose}>
{t('common.close')}
</DialogButton>
</DialogFooter>
}
>
{tabs.length > 1 && (
<Segmented<AiTab> label={t('admin.addons.ai.setup')} value={shown} onChange={setTab} options={tabs} fill />
)}
{!A.loaded ? (
<div className="grid place-items-center py-10">
<Loader2 size={20} className="animate-spin text-content-faint" />
</div>
) : (
<>
{shown === 'connection' && (
<div className="flex flex-col gap-4">
<PolicyChoice A={A} />
{A.instancePays && <InstanceConnection A={A} />}
</div>
)}
{shown === 'assistant' && <AssistantLimits A={A} />}
{shown === 'parsing' && parsing && (
<ParsingSettings addon={parsing} A={A} onOpenConnection={() => setTab('connection')} />
)}
</>
)}
<ConfirmDialog
isOpen={A.instance.confirmingRemove}
@@ -185,6 +265,6 @@ export default function AiAdminConfig(): React.ReactElement {
confirmLabel={t('assistant.admin.connectionRemove')}
danger
/>
</li>
</DialogShell>
);
}
@@ -0,0 +1,348 @@
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared';
import { Check, RefreshCw } from 'lucide-react';
import React, { useId } from 'react';
import { useTranslation } from '../../../i18n';
import { ProviderMark } from '../../Settings/ai/AiConnectPanel';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import { DialogSection, fs } from '../../shared/DialogShell';
import { EditorField, INPUT, PANEL, Segmented } from '../../shared/dialogParts';
import { type Addon, ANTHROPIC_HINT, MASKED, RECOMMENDED_MODELS } from './addonModel';
import type { useAiAdminConfig } from './useAiAdminConfig';
import { useLlmParsingConfig } from './useLlmParsingConfig';
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
type Parsing = ReturnType<typeof useLlmParsingConfig>;
type MarkKind = Parameters<typeof ProviderMark>[0]['kind'];
/** Parsing's own providers, with the mark the assistant shows for each. */
const OWN_PROVIDERS = [
{ value: 'local', labelKey: 'settings.aiParsing.providerLocal', mark: 'custom' },
{ value: 'openai', labelKey: 'settings.aiParsing.providerOpenai', mark: 'openai' },
{ value: 'anthropic', labelKey: 'settings.aiParsing.providerAnthropic', mark: 'anthropic' },
] as const;
const markOf = (provider: string): MarkKind => OWN_PROVIDERS.find((p) => p.value === provider)?.mark ?? 'custom';
/** The connection parsing reads with, as one line: a mark, who answers, the model and where it comes from. */
function ReadsWith({ mark, title, model, tag }: { mark: MarkKind; title: string; model: string; tag: string }) {
return (
<div className="flex items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
<ProviderMark kind={mark} />
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
{title}
</p>
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
{model}
</p>
</div>
<StatusPill>{tag}</StatusPill>
</div>
);
}
/** Which connection parsing reads with now, and the way to the other one. */
function ParsingSource({ A, P, onOpenConnection }: { A: AiAdmin; P: Parsing; onOpenConnection: () => void }) {
const { t } = useTranslation();
const live = A.instancePays ? A.instance.live : null;
if (P.ownConnection) {
const own = OWN_PROVIDERS.find((p) => p.value === P.savedProvider);
return (
<div className="flex flex-col gap-2">
<ReadsWith
mark={markOf(P.savedProvider)}
title={own ? t(own.labelKey) : P.savedProvider}
model={P.savedModel}
tag={t('admin.addons.ai.parsing.own')}
/>
<SettingsHint>{t('admin.addons.ai.parsing.ownHint')}</SettingsHint>
<button
type="button"
onClick={() => void P.switchToShared()}
disabled={P.saving}
className={`${SETTINGS_BUTTON} self-start`}
style={fs(12.5, 'body')}
>
{t('admin.addons.ai.parsing.useShared')}
</button>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{live ? (
<ReadsWith
mark={live.kind}
title={A.instance.provider}
model={live.model}
tag={t('admin.addons.ai.parsing.sharedTag')}
/>
) : (
<SettingsHint>
{t(A.policy === 'user' ? 'admin.addons.ai.parsing.perUser' : 'admin.addons.ai.parsing.none')}
</SettingsHint>
)}
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
{!live && A.policy !== 'user' && (
<button type="button" onClick={onOpenConnection} className={SETTINGS_BUTTON_PRIMARY}>
{t('admin.addons.ai.tab.connection')}
</button>
)}
{!P.formOpen && (
<button type="button" onClick={P.startOwn} className={SETTINGS_BUTTON}>
{t('admin.addons.ai.parsing.setupOwn')}
</button>
)}
</div>
</div>
);
}
/** The models on the local server to pick from, and the recommended ones to pull. */
function LocalModels({ P }: { P: Parsing }) {
const { t } = useTranslation();
return (
<div className={PANEL}>
<div className="flex items-center gap-2">
<span
className="min-w-0 flex-1 truncate font-geist font-bold uppercase tracking-[.08em] text-content-faint"
style={fs(9.5)}
>
{t('admin.addons.ai.parsing.installed')}
</span>
<button
type="button"
onClick={() => void P.loadModels()}
disabled={P.loadingModels}
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-card hover:text-content disabled:cursor-default disabled:opacity-60"
style={fs(11.5, 'body')}
>
<RefreshCw size={11} strokeWidth={2.2} className={P.loadingModels ? 'animate-spin' : undefined} />
{t('admin.addons.ai.parsing.refresh')}
</button>
</div>
{P.modelsErr && (
<p className="m-0 break-words text-danger" style={fs(11.5)}>
{P.modelsErr}
</p>
)}
{!P.modelsErr && P.installed.length === 0 && !P.loadingModels && (
<SettingsHint>{t('admin.addons.ai.parsing.noModels')}</SettingsHint>
)}
{P.installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{P.installed.map((name) => (
<button
type="button"
key={name}
title={name}
onClick={() => P.setModel(name)}
aria-pressed={P.model === name}
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${P.model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
style={fs(11.5)}
>
{name}
</button>
))}
</div>
)}
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
{t('admin.addons.ai.parsing.pullTitle')}
</span>
{RECOMMENDED_MODELS.map((m) => {
const here = P.isInstalled(m.id);
const chosen = P.model === m.id;
return (
<div key={m.id} className="min-w-0" title={m.note}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{m.label}
</span>
{m.recommended && <StatusPill tone="success">{t('assistant.model.recommended')}</StatusPill>}
{here ? (
<button
type="button"
onClick={() => P.setModel(m.id)}
disabled={chosen}
className={SETTINGS_BUTTON}
style={fs(12, 'body')}
>
{chosen ? <Check size={13} strokeWidth={2.4} /> : null}
{t(chosen ? 'admin.addons.ai.parsing.selected' : 'admin.addons.ai.parsing.use')}
</button>
) : (
<button
type="button"
onClick={() => void P.pull(m.id)}
disabled={!!P.pulling}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(12, 'body')}
>
{t(P.pulling === m.id ? 'admin.addons.ai.parsing.pulling' : 'admin.addons.ai.parsing.pull')}
</button>
)}
</div>
{P.pulling === m.id && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div
className="h-full rounded-full bg-accent transition-[width] duration-200"
style={{ width: `${P.pullPct}%` }}
/>
</div>
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>
{P.pullStatus}
{P.pullPct ? ` ${P.pullPct}%` : ''}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/** A connection of parsing's own: the provider as tiles, its address, key and model. */
function OwnConnectionForm({ P }: { P: Parsing }) {
const { t } = useTranslation();
const fieldId = useId();
return (
<div className={`flex flex-col gap-3 ${P.saving ? 'opacity-60' : ''}`}>
<div
role="radiogroup"
aria-label={t('settings.aiParsing.provider')}
className="grid grid-cols-3 gap-2 max-sm:grid-cols-1"
>
{OWN_PROVIDERS.map((p) => {
const on = P.provider === p.value;
return (
<button
key={p.value}
type="button"
role="radio"
aria-checked={on}
onClick={() => P.setProvider(p.value)}
className={`flex min-w-0 items-center gap-2 rounded-[12px] border p-2.5 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
>
<ProviderMark kind={p.mark} size="sm" />
<span
className={`min-w-0 flex-1 truncate font-medium ${on ? 'text-content' : 'text-content-muted'}`}
style={fs(12.5, 'body')}
>
{t(p.labelKey)}
</span>
</button>
);
})}
</div>
<EditorField
label={t('settings.aiParsing.baseUrl')}
htmlFor={`${fieldId}-url`}
hint={t('settings.aiParsing.baseUrlHint')}
>
<input
id={`${fieldId}-url`}
type="url"
autoComplete="off"
className={INPUT}
value={P.baseUrl}
onChange={(e) => P.setBaseUrl(e.target.value)}
onBlur={() => void P.loadModels()}
placeholder={P.baseUrlPlaceholder}
/>
</EditorField>
<EditorField
label={t('settings.aiParsing.apiKey')}
htmlFor={`${fieldId}-key`}
hint={P.provider === 'anthropic' ? ANTHROPIC_HINT : t('settings.aiParsing.apiKeyHint')}
>
<input
id={`${fieldId}-key`}
type="password"
autoComplete="off"
className={INPUT}
value={P.apiKey}
onChange={(e) => P.setApiKey(e.target.value)}
placeholder={
P.apiKey === MASKED ? MASKED : P.provider === 'local' ? t('admin.addons.ai.parsing.apiKeyOptional') : ''
}
/>
</EditorField>
<EditorField label={t('settings.aiParsing.model')} htmlFor={`${fieldId}-model`}>
<input
id={`${fieldId}-model`}
autoComplete="off"
className={`${INPUT} font-geist`}
value={P.model}
onChange={(e) => P.setModel(e.target.value)}
/>
</EditorField>
{P.provider === 'local' && <LocalModels P={P} />}
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
{!P.ownConnection && (
<button type="button" onClick={P.cancelOwn} disabled={P.saving} className={SETTINGS_BUTTON}>
{t('common.cancel')}
</button>
)}
<button
type="button"
onClick={() => void P.save()}
disabled={P.saving || !P.model.trim()}
className={SETTINGS_BUTTON_PRIMARY}
>
{t(P.saving ? 'common.saving' : 'common.save')}
</button>
</div>
</div>
);
}
/**
* AI parsing in the AI dialog: which connection reads the bookings (the shared
* one, or one of its own an older install kept), a connection of its own when
* wanted, and whether the model reads images. Each part saves on its own.
*/
export default function ParsingSettings({
addon,
A,
onOpenConnection,
}: {
addon: Addon;
A: AiAdmin;
onOpenConnection: () => void;
}): React.ReactElement {
const { t } = useTranslation();
const P = useLlmParsingConfig(addon);
const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) }));
const local =
(P.ownConnection ? P.savedProvider : A.instance.live?.kind === 'custom' ? 'local' : 'cloud') === 'local';
return (
<div className="flex flex-col gap-5">
<DialogSection label={t('admin.addons.ai.parsing.readsWith')}>
<ParsingSource A={A} P={P} onOpenConnection={onOpenConnection} />
</DialogSection>
{P.formOpen && (
<DialogSection label={t('admin.addons.ai.parsing.own')}>
<OwnConnectionForm P={P} />
</DialogSection>
)}
<DialogSection label={t('settings.aiParsing.multimodal')}>
<Segmented<LlmVision>
label={t('settings.aiParsing.multimodal')}
value={P.vision}
onChange={(v) => P.setVision(asLlmVision(v))}
options={visionOptions}
fill
/>
<div className="mt-1.5">
<SettingsHint>
{t(local ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
</SettingsHint>
</div>
</DialogSection>
</div>
);
}
@@ -0,0 +1,32 @@
import { Cable, type LucideIcon, MessagesSquare, ScanText } from 'lucide-react';
import type { Addon } from './addonModel';
/**
* The three addons that talk to a language model, shown as one AI tile in the
* integration column: the assistant in the trip, AI parsing (booking import)
* and MCP (TREK inside other AI apps). Their ids, rows and settings stay their
* own; only the page groups them. Assistant and parsing share one connection.
*/
export const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const;
export type AiAddonId = (typeof AI_ADDON_IDS)[number];
/** Each one's glyph, keyed by id: the icon column of an existing install still holds what it was seeded with. */
export const AI_ADDON_ICONS: Record<AiAddonId, LucideIcon> = {
ai_assistant: MessagesSquare,
llm_parsing: ScanText,
mcp: Cable,
};
export function isAiAddon(addon: Addon): boolean {
return (AI_ADDON_IDS as readonly string[]).includes(addon.id);
}
/** The AI addons this install has, in the tile's order. */
export function aiAddonsOf(addons: Addon[]): Addon[] {
return AI_ADDON_IDS.flatMap((id) => addons.find((a) => a.id === id) ?? []);
}
/** Whether the shared connection is used at all: the assistant or AI parsing is on. */
export function usesConnection(addons: Addon[]): boolean {
return addons.some((a) => (a.id === 'ai_assistant' || a.id === 'llm_parsing') && a.enabled);
}
@@ -26,6 +26,8 @@ const ADDONS = [
addon('llm_parsing', 'integration', false),
addon('immich', 'photo_provider', false),
addon('gdrive', 'document_provider'),
addon('airtrail', 'integration', false),
addon('mcp', 'integration'),
];
let loadAddons: ReturnType<typeof vi.fn<() => Promise<void>>>;
@@ -60,14 +62,16 @@ function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' |
}
describe('useAddonManager', () => {
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration, AI and the two shelves', async () => {
const { result, rerender } = await loaded();
rerender();
expect(adminApi.addons).toHaveBeenCalledTimes(1);
const ids = (list: { id?: string; key?: string }[]) => list.map((a) => a.id ?? a.key);
expect(ids(result.current.tripAddons)).toEqual(['todo', 'documents']);
expect(ids(result.current.globalAddons)).toEqual(['journey']);
expect(ids(result.current.integrationAddons)).toEqual(['llm_parsing']);
// The AI addons leave the integration list for their own tile, in its order.
expect(ids(result.current.integrationAddons)).toEqual(['airtrail']);
expect(ids(result.current.aiAddons)).toEqual(['llm_parsing', 'mcp']);
expect(result.current.providerOptions).toEqual([
expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }),
]);
@@ -9,6 +9,7 @@ import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useToast } from '../../shared/Toast';
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
import { aiAddonsOf, isAiAddon } from './aiAddons';
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
function loadToursFlag(
@@ -139,7 +140,9 @@ export function useAddonManager() {
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
const globalAddons = addons.filter((a) => a.type === 'global');
const integrationAddons = addons.filter((a) => a.type === 'integration');
// The AI addons leave the list to sit together on one tile at its head.
const integrationAddons = addons.filter((a) => a.type === 'integration' && !isAiAddon(a));
const aiAddons = aiAddonsOf(addons);
const providerOptions = addons.filter((a) => a.type === 'photo_provider').map(asOption);
// No credential form under these, unlike the photo providers: a document connection
// belongs to a trip and is entered there. The admin only decides whether a provider
@@ -153,6 +156,7 @@ export function useAddonManager() {
tripAddons,
globalAddons,
integrationAddons,
aiAddons,
providerOptions,
documentProviderOptions,
toursWanderer,
@@ -0,0 +1,23 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router';
import type { Addon } from './addonModel';
import { usesConnection } from './aiAddons';
/**
* The AI tile behind both admin shells: its three switches, how many are on,
* and the settings dialog. The dialog opens by itself when OpenRouter's
* sign-in returns here (`?ai=openrouter`), since the connection form that
* finishes it lives inside.
*/
export function useAiAddonTile(aiAddons: Addon[]) {
const [params] = useSearchParams();
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
const canSetUp = usesConnection(aiAddons);
return {
enabledCount: aiAddons.filter((a) => a.enabled).length,
canSetUp,
dialogOpen: open && canSetUp,
openDialog: () => setOpen(true),
closeDialog: () => setOpen(false),
};
}
@@ -5,10 +5,14 @@ import { adminApi } from '../../../api/client';
import { type Addon, DEFAULT_OLLAMA_URL, MASKED } from './addonModel';
import { useLlmParsingConfig } from './useLlmParsingConfig';
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-011
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-013
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
/** A local connection of parsing's own, so the form is open and lists the server's models. */
const OWN_LOCAL = { provider: 'local', model: 'm' };
function llm(config?: Record<string, unknown>): Addon {
return {
@@ -45,7 +49,7 @@ describe('useLlmParsingConfig', () => {
});
it('FE-HOOK-LLMCFG-002: the local provider lists the installed models from the default URL', async () => {
const { result } = renderHook(() => useLlmParsingConfig(llm()));
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL);
expect(result.current.isInstalled('qwen3.5:4b')).toBe(true);
@@ -54,7 +58,7 @@ describe('useLlmParsingConfig', () => {
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
const { result } = renderHook(() =>
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
useLlmParsingConfig(llm({ provider: 'openai', model: 'gpt', baseUrl: ' http://box:11434/v1 ' }))
);
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
await act(() => result.current.loadModels());
@@ -69,13 +73,13 @@ describe('useLlmParsingConfig', () => {
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(new Error('ECONNREFUSED'));
const { result } = renderHook(() => useLlmParsingConfig(llm()));
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
await waitFor(() => expect(result.current.modelsErr).toBe('ECONNREFUSED'));
expect(result.current.installed).toEqual([]);
expect(result.current.loadingModels).toBe(false);
vi.mocked(adminApi.llmLocalModels).mockRejectedValue('nope');
await act(() => result.current.loadModels());
expect(result.current.modelsErr).toBe('Could not reach the local LLM server');
expect(result.current.modelsErr).toBe('admin.addons.ai.parsing.unreachable');
});
it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => {
@@ -83,7 +87,7 @@ describe('useLlmParsingConfig', () => {
response: { status: 502, data: { error: 'Local LLM server rejected the API key (401)' } },
});
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(refused);
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)'));
});
@@ -94,14 +98,14 @@ describe('useLlmParsingConfig', () => {
progress = onProgress;
return new Promise<void>((r) => (finish = r));
});
const { result } = renderHook(() => useLlmParsingConfig(llm()));
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
let pending!: Promise<void>;
act(() => {
pending = result.current.pull('qwen3.5:4b');
});
expect(result.current.pulling).toBe('qwen3.5:4b');
expect(result.current.pullStatus).toBe('starting…');
expect(result.current.pullStatus).toBe('admin.addons.ai.parsing.pulling');
act(() => progress({ status: 'downloading', total: 200, completed: 50 }));
expect(result.current.pullStatus).toBe('downloading');
expect(result.current.pullPct).toBe(25);
@@ -109,7 +113,7 @@ describe('useLlmParsingConfig', () => {
finish();
await pending;
});
expect(toast.success).toHaveBeenCalledWith('Model pulled');
expect(toast.success).toHaveBeenCalledWith('admin.addons.ai.parsing.pulled');
expect(result.current.model).toBe('qwen3.5:4b');
expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2);
expect(result.current.pulling).toBeNull();
@@ -136,7 +140,7 @@ describe('useLlmParsingConfig', () => {
expect(toast.error).toHaveBeenCalledWith('manifest unknown');
vi.mocked(adminApi.llmLocalPull).mockRejectedValue('nope');
await act(() => result.current.pull('x'));
expect(toast.error).toHaveBeenLastCalledWith('Pull failed');
expect(toast.error).toHaveBeenLastCalledWith('admin.addons.ai.parsing.pullFailed');
expect(result.current.pulling).toBeNull();
});
@@ -148,18 +152,16 @@ describe('useLlmParsingConfig', () => {
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', {
config: { provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o/v1', apiKey: MASKED, vision: 'auto' },
});
expect(toast.success).toHaveBeenCalledWith('Saved');
expect(toast.success).toHaveBeenCalledWith('settings.aiParsing.toast.saved');
expect(result.current.saving).toBe(false);
});
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
act(() => {
result.current.setProvider('anthropic');
result.current.setVision('off');
});
await act(async () => result.current.setVision('off'));
act(() => result.current.setProvider('anthropic'));
await act(() => result.current.save());
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1]).toEqual({
expect(vi.mocked(adminApi.updateAddon).mock.calls.at(-1)?.[1]).toEqual({
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
});
});
@@ -212,7 +214,35 @@ describe('useLlmParsingConfig', () => {
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
await act(() => result.current.save());
expect(toast.error).toHaveBeenCalledWith('Failed to save');
expect(toast.error).toHaveBeenCalledWith('settings.aiParsing.toast.saveError');
expect(result.current.saving).toBe(false);
});
it('FE-HOOK-LLMCFG-012: without its own connection the form stays shut and asks the server nothing until opened', async () => {
const { result } = renderHook(() => useLlmParsingConfig(llm({ vision: 'on' })));
expect(result.current.ownConnection).toBe(false);
expect(result.current.formOpen).toBe(false);
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
act(() => result.current.startOwn());
expect(result.current.formOpen).toBe(true);
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
// The vision setting alone is saved without a connection.
await act(async () => result.current.setVision('off'));
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('llm_parsing', { config: { vision: 'off' } });
act(() => result.current.cancelOwn());
expect(result.current.formOpen).toBe(false);
});
it('FE-HOOK-LLMCFG-013: going back to the shared connection drops the own one and keeps the vision setting', async () => {
const { result } = renderHook(() =>
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'claude-x', apiKey: MASKED, vision: 'on' }))
);
expect(result.current.ownConnection).toBe(true);
expect(result.current.savedModel).toBe('claude-x');
await act(() => result.current.switchToShared());
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', { config: { vision: 'on' } });
expect(result.current.ownConnection).toBe(false);
expect(result.current.formOpen).toBe(false);
expect(result.current.model).toBe('');
});
});
@@ -2,24 +2,47 @@ import { type LlmVision, asLlmVision } from '@trek/shared';
import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { useTranslation } from '../../../i18n';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
/** What AI parsing keeps in its addon config: a connection of its own (or none) and the vision setting. */
interface SavedParsing {
provider: string;
model: string;
baseUrl: string;
apiKey: string;
vision: LlmVision;
}
function savedOf(cfg: Record<string, unknown>): SavedParsing {
return {
provider: typeof cfg.provider === 'string' ? cfg.provider : '',
model: typeof cfg.model === 'string' ? cfg.model : '',
baseUrl: typeof cfg.baseUrl === 'string' ? cfg.baseUrl : '',
apiKey: typeof cfg.apiKey === 'string' ? cfg.apiKey : '',
vision: asLlmVision(cfg.vision),
};
}
/**
* Instance-wide AI-parsing config behind both admin shells. When set, it applies to the
* whole instance and overrides per-user config (see server llmConfig.ts). The API key is
* masked on read; an unchanged mask is treated as a no-op by the server. For the local
* provider it also lists the installed Ollama models and can pull the recommended ones.
* AI parsing's admin settings behind both admin shells. Parsing reads with the
* AI connection the assistant runs on unless the instance keeps one of its own
* here; an install set up before the two shared one keeps its own until the
* admin switches it over. The API key is masked on read and an unchanged mask
* keeps the stored key. For a local server it lists the installed Ollama models
* and pulls the recommended ones.
*/
export function useLlmParsingConfig(addon: Addon) {
const { t } = useTranslation();
const toast = useToast();
const cfg = addon.config ?? {};
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
const [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision));
const [saved, setSaved] = useState<SavedParsing>(() => savedOf(addon.config ?? {}));
const [editing, setEditing] = useState(false);
const [provider, setProviderState] = useState<string>(saved.provider || 'local');
const [model, setModel] = useState<string>(saved.model);
const [baseUrl, setBaseUrl] = useState<string>(saved.baseUrl);
const [apiKey, setApiKey] = useState<string>(saved.apiKey);
const [saving, setSaving] = useState(false);
// Local-provider model management.
@@ -30,6 +53,10 @@ export function useLlmParsingConfig(addon: Addon) {
const [pullPct, setPullPct] = useState(0);
const [pullStatus, setPullStatus] = useState('');
/** A connection of its own, which wins over the shared one. */
const ownConnection = !!(saved.provider && saved.model);
const formOpen = ownConnection || editing;
/**
* A different provider never inherits the last one's endpoint: an Ollama address
* left in the field would quietly receive the OpenAI or Anthropic key. Back on the
@@ -37,7 +64,7 @@ export function useLlmParsingConfig(addon: Addon) {
* keep an address the admin typed for it (#2655); empty means the provider default.
*/
const setProvider = (next: string) => {
if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : '');
if (next !== provider) setBaseUrl(next === saved.provider ? saved.baseUrl : '');
setProviderState(next);
};
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
@@ -54,38 +81,38 @@ export function useLlmParsingConfig(addon: Addon) {
setInstalled(res.models.map((m) => m.name));
} catch (e: unknown) {
// The server says why (unreachable, key refused); axios' own status line does not.
setModelsErr(getApiErrorMessage(e, 'Could not reach the local LLM server'));
setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable')));
setInstalled([]);
} finally {
setLoadingModels(false);
}
};
// Load installed models when the local provider is active. Only a provider change
// triggers it; the base URL is read as it stands then (its own blur reloads).
// Load installed models while the form shows the local provider. Only a provider
// change (or opening the form) triggers it; the base URL's own blur reloads.
const loadModelsForProvider = useEffectEvent(() => {
if (provider === 'local') void loadModels();
if (formOpen && provider === 'local') void loadModels();
});
useEffect(() => {
loadModelsForProvider();
}, [provider]);
}, [provider, formOpen]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
setPullStatus('starting…');
setPullStatus(t('admin.addons.ai.parsing.pulling'));
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error);
if (p.status) setPullStatus(p.status);
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
});
toast.success('Model pulled');
toast.success(t('admin.addons.ai.parsing.pulled'));
setModel(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed');
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
} finally {
setPulling(null);
setPullPct(0);
@@ -93,27 +120,52 @@ export function useLlmParsingConfig(addon: Addon) {
}
};
const save = async () => {
/** Writes the config; the stored key stays as long as the mask is sent in its place. */
const write = async (next: SavedParsing, config: Record<string, unknown>): Promise<boolean> => {
setSaving(true);
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, {
config: {
provider,
model: model.trim(),
baseUrl: baseUrl.trim(),
apiKey,
vision,
},
});
toast.success('Saved');
await adminApi.updateAddon(addon.id, { config });
setSaved(next);
toast.success(t('settings.aiParsing.toast.saved'));
return true;
} catch {
toast.error('Failed to save');
toast.error(t('settings.aiParsing.toast.saveError'));
return false;
} finally {
setSaving(false);
}
};
const ownConfig = (s: SavedParsing) => ({
provider: s.provider,
model: s.model,
baseUrl: s.baseUrl,
apiKey: s.apiKey,
});
const save = async () => {
const next: SavedParsing = { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, vision: saved.vision };
if (await write(next, { ...ownConfig(next), vision: next.vision })) setEditing(false);
};
/** Back on the shared connection: the own one and its key are dropped, the vision setting stays. */
const switchToShared = async () => {
const next: SavedParsing = { provider: '', model: '', baseUrl: '', apiKey: '', vision: saved.vision };
if (await write(next, { vision: next.vision })) {
setEditing(false);
setProviderState('local');
setModel('');
setBaseUrl('');
setApiKey('');
}
};
/** Saved at once, with the connection as it was saved: a draft in the form is not sent along. */
const setVision = (value: LlmVision) => {
const next = { ...saved, vision: value };
void write(next, ownConnection ? { ...ownConfig(saved), vision: value } : { vision: value });
};
return {
provider,
setProvider,
@@ -124,9 +176,16 @@ export function useLlmParsingConfig(addon: Addon) {
baseUrlPlaceholder,
apiKey,
setApiKey,
vision,
vision: saved.vision,
setVision,
saving,
ownConnection,
savedProvider: saved.provider,
savedModel: saved.model,
formOpen,
startOwn: () => setEditing(true),
cancelOwn: () => setEditing(false),
switchToShared,
installed,
modelsErr,
loadingModels,
@@ -226,10 +226,13 @@ describe('AssistantChangeCard: on the map', () => {
expect(row).toHaveClass('bg-surface-hover');
});
it('FE-AI-CARD-009: the days a waiting card touches read as a band, with the schedule folded away', () => {
it('FE-AI-CARD-009: the days a waiting card touches read as named stops, with the schedule folded away', () => {
plannerHere();
renderCard([onTheMap('c1', 'Pantheon')]);
expect(screen.getByText('Rome')).toBeInTheDocument();
// Folded, each stop is a chip with its name, not a blank block behind a tooltip.
expect(screen.getByText('Louvre')).toBeInTheDocument();
expect(screen.getAllByText('Pantheon')).toHaveLength(2);
expect(screen.queryByText('New')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show schedule' }));
@@ -25,14 +25,16 @@ import { blockKeyOf } from '../../store/assistantMapModel';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import ToggleSwitch from '../Settings/ToggleSwitch';
import { Tooltip } from '../shared/Tooltip';
import { useToast } from '../shared/Toast';
import { Tooltip } from '../shared/Tooltip';
import AssistantCompareSwitch from './AssistantCompareSwitch';
import AssistantDayBand from './AssistantDayBand';
import AssistantWeekOverview from './AssistantWeekOverview';
import { CHANGE_LABELS, type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { changePoints, type DayChange } from './dayAfterModel';
import { useChangeHover } from './useChangeHover';
import { useWeekDays } from './useWeekDays';
/**
* The changes the assistant proposes in one go, as one card in plain words:
@@ -235,7 +237,11 @@ function Receipt({
</Tooltip>
)}
<Tooltip label={statusLabel} placement="top">
<span role="img" aria-label={statusLabel} className={`flex h-7 w-5 items-center justify-center ${receipt.tone}`}>
<span
role="img"
aria-label={statusLabel}
className={`flex h-7 w-5 items-center justify-center ${receipt.tone}`}
>
<Icon size={15} strokeWidth={2.4} className={item.state === 'applying' ? 'animate-spin' : ''} />
</span>
</Tooltip>
@@ -392,6 +398,21 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
const changes = useMemo(() => dayChangesOf(items, excluded), [items, excluded]);
const undoable = items.filter((i) => undoableNow(i, undone, now)).map((i) => i.toolCallId);
const week = useWeekDays(pending);
const [showAll, setShowAll] = useState(false);
// A plan over a week reads from its grid; the single changes wait behind one line.
const folded = week.length > 0 && !showAll;
const toggleDay = (ids: readonly string[], include: boolean) =>
setExcluded((prev) => {
const next = new Set(prev);
for (const id of ids) {
if (include) next.delete(id);
else next.add(id);
}
return next;
});
const toggle = (toolCallId: string) =>
setExcluded((prev) => {
const next = new Set(prev);
@@ -420,18 +441,32 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
count={items.length}
actions={<HeadActions open={open} items={items} changes={changes} undoable={undoable} />}
/>
{open && <AssistantDayBand changes={changes} />}
{open && week.length > 0 && (
<AssistantWeekOverview days={week} excluded={excluded} disabled={isRunning} onToggleDay={toggleDay} />
)}
{open && !folded && <AssistantDayBand changes={changes} />}
{folded && (
<button
type="button"
onClick={() => setShowAll(true)}
className="mb-1 w-full rounded-lg px-2 py-1.5 text-center text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
{t('assistant.week.showAll', { count: pending.length })}
</button>
)}
<div className={open ? 'divide-y divide-edge-faint' : ''}>
{items.map((item) =>
item.state === 'pending' ? (
<PendingRow
key={item.toolCallId}
item={item}
excluded={excluded.has(item.toolCallId)}
withSwitch={pending.length > 1}
disabled={isRunning}
onToggle={() => toggle(item.toolCallId)}
/>
folded ? null : (
<PendingRow
key={item.toolCallId}
item={item}
excluded={excluded.has(item.toolCallId)}
withSwitch={pending.length > 1}
disabled={isRunning}
onToggle={() => toggle(item.toolCallId)}
/>
)
) : (
<Receipt
key={item.toolCallId}
@@ -111,7 +111,7 @@ export default function AssistantComposer({ tripId, tripTitle }: { tripId: numbe
onPaste={(e) => {
if (e.clipboardData.files.length > 0 && take(e.clipboardData.files)) e.preventDefault();
}}
className="max-h-48 min-h-[36px] flex-1 resize-none bg-transparent px-1.5 py-1.5 text-body text-content outline-none placeholder:text-content-faint disabled:cursor-not-allowed"
className="max-h-48 min-h-[36px] flex-1 resize-none bg-transparent px-1.5 py-1.5 text-body text-content outline-none placeholder:text-content-faint disabled:cursor-not-allowed dark:bg-transparent"
/>
<AuiIf condition={(s) => s.thread.isRunning}>
<ComposerPrimitive.Cancel
@@ -1,4 +1,4 @@
import { ChevronDown, TriangleAlert } from 'lucide-react';
import { ChevronDown, CircleCheck, TriangleAlert } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
@@ -7,38 +7,49 @@ import { useTripStore } from '../../store/tripStore';
import { dayLabel } from '../../utils/dayLabel';
import { Tooltip } from '../shared/Tooltip';
import { useAssistantView } from './assistantViewContext';
import { buildDayAfter, changedDays, type DayAfter, type DayChange, type TimelineStop } from './dayAfterModel';
import {
buildDayAfter,
changedDays,
type DayAfter,
type DayChange,
dayIssues,
stopIssue,
type TimelineStop,
} from './dayAfterModel';
import { useChangeHover } from './useChangeHover';
/**
* The days a waiting card touches, as they would read once it ran: one thin
* band per day with a block per stop, so a glance shows where the suggestion
* lands among the rest. The details (times, the way between stops, free time,
* a clash) stay folded away until asked for. Two days at most; the rest are
* counted. Shown only while the planner holds this trip, since the day plans
* come from it.
* The days a waiting card touches, as they would read once it ran: per day its
* date and its stops in order as small named chips, so a glance shows where the
* suggestion lands among the rest. The details (times, the way between stops,
* free time, a clash) stay folded away until asked for. Two days at most; the
* rest are counted. Shown only while the planner holds this trip, since the day
* plans come from it.
*/
/** Days drawn as a band; the rest are counted. */
/** Days shown; the rest are counted. */
const MAX_DAYS = 2;
/** Stops shown as chips on a day; the rest are counted. */
const MAX_CHIPS = 6;
const SEGMENT: Record<TimelineStop['status'], string> = {
kept: 'bg-surface-secondary',
new: 'border border-dashed border-success bg-success-soft',
removed: 'border border-edge-faint opacity-50',
const CHIP: Record<TimelineStop['status'], string> = {
kept: 'border-transparent bg-surface-secondary text-content-muted',
new: 'border-dashed border-success bg-success-soft text-content',
removed: 'border-transparent text-content-faint line-through',
};
function Segment({ stop }: { stop: TimelineStop }) {
/** One stop of the day, named; the change it belongs to lights up with it. */
function StopChip({ stop }: { stop: TimelineStop }) {
const { tripId } = useAssistantView();
const { lit, handlers } = useChangeHover(tripId, stop.toolCallId);
const clash = stop.lead?.overlapsWith ? 'ring-1 ring-inset ring-danger' : '';
const clash = stop.lead?.overlapsWith ? 'border-danger' : stopIssue(stop) ? 'border-warning' : '';
return (
<Tooltip label={stop.name} placement="top">
<span
{...(stop.toolCallId ? handlers : {})}
className={`h-full min-w-[6px] flex-1 rounded-sm transition-shadow ${SEGMENT[stop.status]} ${clash} ${lit ? 'ring-2 ring-inset ring-accent' : ''}`}
/>
</Tooltip>
<span
{...(stop.toolCallId ? handlers : {})}
className={`inline-flex max-w-[11rem] items-center rounded-full border px-2 py-0.5 text-caption transition-colors ${CHIP[stop.status]} ${clash} ${lit ? 'bg-surface-hover' : ''}`}
>
<bdi className="truncate">{stop.name}</bdi>
</span>
);
}
@@ -47,13 +58,19 @@ function Lead({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
const lead = stop.lead;
if (!lead) return null;
const travel = lead.travelMinutes !== null && lead.travelMinutes > 0;
if (!travel && lead.freeMinutes === null && !lead.overlapsWith) return null;
if (!travel && lead.freeMinutes === null && !lead.overlapsWith && lead.shortBy === null) return null;
return (
<li
className={`flex flex-wrap items-center gap-x-2 text-caption text-content-faint ${timed ? 'ps-[3.25rem]' : 'ps-1'}`}
>
{travel && <span>{t('assistant.change.timeline.travel', { count: lead.travelMinutes ?? 0 })}</span>}
{lead.freeMinutes !== null && <span>{t('assistant.change.timeline.gap', { count: lead.freeMinutes })}</span>}
{lead.shortBy !== null && (
<span className="inline-flex items-center gap-1 font-medium text-warning">
<TriangleAlert size={11} aria-hidden="true" className="flex-shrink-0" />
{t('assistant.change.timeline.tight', { count: lead.shortBy })}
</span>
)}
{lead.overlapsWith && (
<span className="inline-flex items-center gap-1 text-content-muted">
<TriangleAlert size={11} aria-hidden="true" className="flex-shrink-0 text-danger" />
@@ -91,16 +108,20 @@ function StopLine({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
function DayRow({ day, label, open }: { day: DayAfter; label: string; open: boolean }) {
const timed = day.stops.some((s) => s.time !== null);
const chips = day.stops.slice(0, MAX_CHIPS);
return (
<div>
<div className="flex items-center gap-2">
<bdi className="w-16 flex-shrink-0 truncate text-caption text-content-muted">{label}</bdi>
<div className="flex h-6 min-w-0 flex-1 gap-0.5 overflow-hidden" aria-hidden="true">
{day.stops.map((stop) => (
<Segment key={stop.key} stop={stop} />
<bdi className="block truncate text-caption font-medium text-content-muted">{label}</bdi>
{!open && (
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
{chips.map((stop) => (
<StopChip key={stop.key} stop={stop} />
))}
{day.stops.length > MAX_CHIPS && (
<span className="text-caption tabular-nums text-content-faint">+{day.stops.length - MAX_CHIPS}</span>
)}
</div>
</div>
)}
{open && (
<ol className="trek-assistant-enter mt-1 space-y-0.5" aria-label={label}>
{day.stops.map((stop) => (
@@ -153,6 +174,8 @@ export default function AssistantDayBand({ changes }: { changes: readonly DayCha
if (rows.length === 0) return null;
const shown = rows.slice(0, MAX_DAYS);
const issues = rows.reduce((sum, row) => sum + dayIssues(row.after), 0);
const measured = rows.some((row) => row.after.stops.some((stop) => stop.lead?.travelMinutes != null));
const toggleLabel = t(open ? 'assistant.change.timeline.hide' : 'assistant.change.timeline.show');
return (
<div className="mb-1 flex gap-1.5">
@@ -161,18 +184,31 @@ export default function AssistantDayBand({ changes }: { changes: readonly DayCha
<DayRow key={after.dayId} day={after} label={label} open={open} />
))}
{rows.length > MAX_DAYS && (
<p className="ps-[4.5rem] text-caption text-content-faint">
<p className="text-caption text-content-faint">
{t('assistant.change.timeline.moreDays', { count: rows.length - MAX_DAYS })}
</p>
)}
</div>
{(issues > 0 || measured) && (
// What the check found, at a glance; a tap opens the schedule that says where.
<button
type="button"
onClick={() => setOpen(true)}
className={`inline-flex h-6 flex-shrink-0 items-center gap-1 self-start rounded-full px-2 text-caption font-medium transition-colors ${
issues > 0 ? 'bg-warning-soft text-warning hover:brightness-95' : 'bg-success-soft text-success'
}`}
>
{issues > 0 ? <TriangleAlert size={12} aria-hidden="true" /> : <CircleCheck size={12} aria-hidden="true" />}
{issues > 0 ? t('assistant.check.issues', { count: issues }) : t('assistant.check.ok')}
</button>
)}
<Tooltip label={toggleLabel} placement="top">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={toggleLabel}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-content-muted outline-none transition-colors hover:bg-surface-hover hover:text-content focus-visible:ring-2 focus-visible:ring-accent"
>
<ChevronDown
size={14}
@@ -0,0 +1,137 @@
import type { AssistantChangeEffect, AssistantMessage } from '@trek/shared';
import { ArrowRightLeft, Minus, Sparkles } from 'lucide-react';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../i18n';
import { changeEffects } from '../../store/assistantMapModel';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
/** What a waiting change does to one day, as a row of the preview. */
interface PreviewRow {
toolCallId: string;
kind: 'add' | 'in' | 'remove' | 'out';
name: string;
}
/** The changes no one has decided on yet, by their call. */
function waitingCalls(messages: readonly AssistantMessage[]): Set<string> {
const ids = new Set<string>();
for (const m of messages) {
for (const p of m.parts) {
if (p.type === 'tool-call' && p.approval && !p.approval.resolution && p.approval.approved === undefined) {
ids.add(p.toolCallId);
}
}
}
return ids;
}
function rowOf(
toolCallId: string,
effect: AssistantChangeEffect,
dayId: number,
nameOf: (id: number | null) => string | null
) {
const name = effect.place?.name ?? nameOf(effect.placeId);
if (!name) return null;
if (effect.dayId === dayId) {
const kind = effect.kind === 'remove' ? 'remove' : effect.kind === 'move' ? 'in' : 'add';
return { toolCallId, kind, name } satisfies PreviewRow;
}
if (effect.kind === 'move' && effect.fromDayId === dayId)
return { toolCallId, kind: 'out', name } satisfies PreviewRow;
return null;
}
const LOOK: Record<PreviewRow['kind'], { icon: typeof Sparkles; row: string; name: string; badgeKey: string }> = {
add: {
icon: Sparkles,
row: 'border-dashed border-accent bg-accent-subtle',
name: 'text-content',
badgeKey: 'assistant.preview.suggested',
},
in: {
icon: ArrowRightLeft,
row: 'border-dashed border-accent bg-accent-subtle',
name: 'text-content',
badgeKey: 'assistant.preview.movedHere',
},
remove: {
icon: Minus,
row: 'border-dashed border-edge',
name: 'text-content-faint line-through',
badgeKey: 'assistant.preview.removed',
},
out: {
icon: ArrowRightLeft,
row: 'border-dashed border-edge',
name: 'text-content-faint line-through',
badgeKey: 'assistant.preview.movedAway',
},
};
/**
* The assistant's waiting changes inside the day they touch, in the planner's
* own day list: a place it would add as a dashed row, one it would take off
* struck through, a move on both of its days. Resting on a row lights the
* change's pin and card; a click brings the chat forward to decide. Nothing
* here writes; the change card stays the one place a change is applied.
*/
export default function AssistantDayPreview({ tripId, dayId }: { tripId: number; dayId: number }) {
const { t } = useTranslation();
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
const hovered = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null));
const { setMapHover, open } = useAssistantStore(useShallow((s) => ({ setMapHover: s.setMapHover, open: s.open })));
const places = useTripStore((s) => s.places);
const rows = useMemo(() => {
const waiting = waitingCalls(messages);
if (waiting.size === 0) return [];
const nameOf = (id: number | null) => (id === null ? null : (places.find((p) => p.id === id)?.name ?? null));
const out: PreviewRow[] = [];
for (const [toolCallId, effect] of changeEffects(messages)) {
if (!waiting.has(toolCallId)) continue;
const row = rowOf(toolCallId, effect, dayId, nameOf);
if (row) out.push(row);
}
return out;
}, [messages, places, dayId]);
if (rows.length === 0) return null;
return (
<ul className="m-0 flex list-none flex-col gap-1 px-3 pb-2 pt-1" aria-label={t('assistant.preview.title')}>
{rows.map((row, i) => {
const look = LOOK[row.kind];
const Icon = look.icon;
const lit = hovered === row.toolCallId;
return (
<li
key={`${row.toolCallId}-${row.kind}`}
className="trek-assistant-enter"
style={{ animationDelay: `${i * 50}ms` }}
>
<button
type="button"
onClick={open}
onMouseEnter={() => setMapHover(tripId, row.toolCallId, 'card')}
onMouseLeave={() => setMapHover(tripId, null, 'card')}
className={`flex w-full items-center gap-2 rounded-xl border px-2.5 py-1.5 text-start transition-all ${look.row} ${
lit ? 'shadow-md ring-2 ring-accent' : 'hover:shadow-sm'
}`}
>
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-lg bg-surface-card text-content-muted shadow-sm">
<Icon size={12} aria-hidden="true" />
</span>
<bdi className={`min-w-0 flex-1 truncate text-caption font-medium ${look.name}`}>{row.name}</bdi>
<span className="flex-shrink-0 rounded-full bg-surface-card px-2 py-0.5 text-caption text-content-muted shadow-sm">
{t(look.badgeKey)}
</span>
</button>
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,58 @@
import type { AssistantMessage } from '@trek/shared';
import { CornerDownRight } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useCanDo } from '../../store/permissionsStore';
import { useTripStore } from '../../store/tripStore';
import { followUpsFor } from './assistantFollowUpRules';
import { hasUndecided } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
/** Each chip comes in a moment after the one before it. */
const STAGGER_MS = 60;
/**
* The next questions under the latest answer, one tap each. Only under the
* last answer, once it is written, and not while changes wait for a decision:
* then the cards are what to answer.
*/
export default function AssistantFollowUps({ message }: { message: AssistantMessage }) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const can = useCanDo();
const trip = useTripStore((s) => (s.trip?.id === tripId ? s.trip : null));
const send = useAssistantStore((s) => s.send);
const shown = useAssistantStore((s) => {
const chat = chatOf(s, tripId);
const last = chat.messages[chat.messages.length - 1];
return !chat.isRunning && !chat.error && last?.id === message.id && !hasUndecided(chat.messages);
});
if (!shown) return null;
const chips = followUpsFor(message, !!trip && can('place_edit', trip));
if (chips.length === 0) return null;
return (
<div role="group" aria-label={t('assistant.followUp.title')} className="flex flex-wrap gap-1.5 pt-0.5">
{chips.map((chip, i) => {
const text = t(chip.labelKey);
return (
<button
key={chip.id}
type="button"
onClick={() => void send(tripId, text)}
className="trek-assistant-enter group/chip inline-flex max-w-full items-center gap-1.5 rounded-full bg-surface-card px-3 py-1.5 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md hover:ring-edge active:translate-y-0"
style={{ animationDelay: `${i * STAGGER_MS}ms` }}
>
<CornerDownRight
size={12}
aria-hidden="true"
className="flex-shrink-0 text-content-faint transition-colors group-hover/chip:text-content rtl:-scale-x-100"
/>
<span className="truncate">{text}</span>
</button>
);
})}
</div>
);
}
@@ -1,24 +1,28 @@
import { Ellipsis, History, Lightbulb, LightbulbOff, Plus, Settings, Trash2, X } from 'lucide-react';
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n';
import { PopoverItem } from '../Packing/PackingPopover';
import ConfirmDialog from '../shared/ConfirmDialog';
import { NEUTRAL_TINT } from '../shared/DialogShell';
import { ContextMenu, useContextMenu } from '../shared/ContextMenu';
import { NEUTRAL_TINT } from '../shared/DialogShell';
import Tooltip from '../shared/Tooltip';
import { PopoverListPanel } from '../shared/dialogParts';
import { usePopoverList } from '../shared/usePopoverList';
import AssistantMemoryDialog from './AssistantMemoryDialog';
import { AI_SETTINGS_PATH } from './assistantParts';
import { useAssistantHintsSetting } from './useAssistantHints';
import { useAssistantLiveStep } from './useAssistantLiveStep';
import type { useAssistantThread } from './useAssistantThread';
import { useTripMemory } from './useTripMemory';
// The round, raised buttons of TREK's dialog heads.
const ICON_BUTTON =
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content';
interface AssistantHeaderProps {
tripId: number;
thread: ReturnType<typeof useAssistantThread>;
tripTitle: string;
onClose: () => void;
@@ -34,8 +38,11 @@ interface AssistantHeaderProps {
* keeps the conversation, and an answer being written goes on. The model is not picked here;
* that lives in the settings.
*/
export default function AssistantHeader({ thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
const { t, locale } = useTranslation();
const live = useAssistantLiveStep(tripId);
const memory = useTripMemory(tripId);
const [memoryOpen, setMemoryOpen] = useState(false);
const navigate = useNavigate();
const menu = useContextMenu();
const [confirming, setConfirming] = useState(false);
@@ -90,9 +97,20 @@ export default function AssistantHeader({ thread, tripTitle, onClose, dragProps
className="flex min-w-0 flex-1 cursor-grab touch-none select-none items-center active:cursor-grabbing"
>
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-geist text-caption font-bold uppercase tracking-[.08em] text-content-muted">
{t('assistant.launcher.tooltip')}
</p>
{thread.isRunning ? (
// While it works, the line over the title says what it does right now.
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption font-medium">
<span
aria-hidden="true"
className="trek-assistant-pulse h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
/>
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
</p>
) : (
<p className="m-0 truncate font-geist text-caption font-bold uppercase tracking-[.08em] text-content-muted">
{t('assistant.launcher.tooltip')}
</p>
)}
<h2 className="m-0 truncate text-body font-bold tracking-[-0.01em] text-content">
<bdi>{thread.title ?? tripTitle}</bdi>
</h2>
@@ -104,6 +122,22 @@ export default function AssistantHeader({ thread, tripTitle, onClose, dragProps
onPointerDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
<button
type="button"
onClick={() => setMemoryOpen(true)}
aria-label={t('assistant.memory.title')}
className={`relative ${ICON_BUTTON}`}
>
<NotebookPen size={13} aria-hidden="true" />
{memory.has && (
<span
aria-hidden="true"
className="absolute -end-0.5 -top-0.5 h-2 w-2 rounded-full bg-accent ring-2 ring-[color:var(--bg-elevated)]"
/>
)}
</button>
</Tooltip>
<Tooltip label={t('assistant.history.open')}>
<button
ref={history.triggerRef}
@@ -167,6 +201,7 @@ export default function AssistantHeader({ thread, tripTitle, onClose, dragProps
</PopoverListPanel>
<ContextMenu menu={menu.menu} onClose={menu.close} />
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<ConfirmDialog
isOpen={confirming}
onClose={() => setConfirming(false)}
@@ -0,0 +1,112 @@
import { NotebookPen, Plus } from 'lucide-react';
import { useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { TEXTAREA } from '../shared/dialogParts';
import { MAX_MEMORY_CHARS, useTripMemory } from './useTripMemory';
/** What travellers most often want minded, one tap to add each. */
const QUICK = ['vegetarian', 'kids', 'budget', 'slow', 'accessible', 'noMuseums'] as const;
/**
* The trip's notes for the assistant: a few lines it follows in every
* suggestion on this trip, written once instead of in every question. The
* common ones are a tap away; the rest is free text.
*/
export default function AssistantMemoryDialog({ tripId, onClose }: { tripId: number; onClose: () => void }) {
const { t } = useTranslation();
const labelId = useId();
const memory = useTripMemory(tripId);
const [draft, setDraft] = useState(memory.text);
const [saving, setSaving] = useState(false);
const add = (line: string) =>
setDraft((prev) => (prev.includes(line) ? prev : `${prev.trimEnd()}${prev.trim() ? '\n' : ''}${line}`));
const save = async () => {
setSaving(true);
const done = await memory.save(draft);
setSaving(false);
if (done) onClose();
};
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="narrow"
discardGuard={draft}
header={
<DialogHeader
tile={
<DialogTile>
<NotebookPen size={20} strokeWidth={2} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
eyebrow={t('assistant.launcher.tooltip')}
title={t('assistant.memory.title')}
sub={t('assistant.memory.sub')}
subWraps
onClose={onClose}
/>
}
footer={
<DialogFooter>
<span className="text-caption tabular-nums text-content-faint">
{draft.length}/{MAX_MEMORY_CHARS}
</span>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" disabled={saving} onClick={() => void save()}>
{t(saving ? 'common.saving' : 'common.save')}
</DialogButton>
</DialogFooter>
}
>
<DialogSection label={t('assistant.memory.quick')}>
<div className="flex flex-wrap gap-1.5">
{QUICK.map((key, i) => {
const line = t(`assistant.memory.quick.${key}`);
const taken = draft.includes(line);
return (
<button
key={key}
type="button"
disabled={taken}
onClick={() => add(line)}
className="trek-assistant-enter inline-flex items-center gap-1 rounded-full bg-surface-card px-2.5 py-1 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md disabled:translate-y-0 disabled:opacity-40 disabled:shadow-none"
style={{ animationDelay: `${i * 40}ms` }}
>
<Plus size={11} aria-hidden="true" />
{line}
</button>
);
})}
</div>
</DialogSection>
<DialogSection label={t('assistant.memory.notes')}>
<textarea
aria-label={t('assistant.memory.notes')}
rows={6}
maxLength={MAX_MEMORY_CHARS}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={t('assistant.memory.placeholder')}
className={TEXTAREA}
/>
</DialogSection>
</DialogShell>
);
}
@@ -7,6 +7,7 @@ import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { Tooltip } from '../shared/Tooltip';
import AssistantChangeCard from './AssistantChangeCard';
import AssistantFollowUps from './AssistantFollowUps';
import AssistantMarkdown from './AssistantMarkdown';
import AssistantMentionText from './AssistantMentionText';
import AssistantPlaceStrip from './AssistantPlaceStrip';
@@ -74,7 +75,9 @@ function AttachmentChip({ name, size }: { name: string; size: number }) {
</span>
<span className="flex min-w-0 flex-col">
<bdi className="truncate text-body font-medium text-content">{name}</bdi>
<span className="text-caption tabular-nums text-content-muted">{kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb} KB`}</span>
<span className="text-caption tabular-nums text-content-muted">
{kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb} KB`}
</span>
</span>
</span>
);
@@ -149,6 +152,7 @@ function Answer({ message }: { message: StoredMessage }) {
</div>
)}
{share.canShare && <AssistantShareDialog share={share} />}
<AssistantFollowUps message={message} />
</MessagePrimitive.Root>
);
}
@@ -1,9 +1,8 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { Check, ChevronDown, LoaderCircle, MapPin, Plus } from 'lucide-react';
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
import { type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { PopoverItem } from '../Packing/PackingPopover';
import { getCategoryIcon } from '../shared/categoryIcons';
import { PopoverListPanel } from '../shared/dialogParts';
import { Tooltip } from '../shared/Tooltip';
@@ -22,7 +21,7 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
const { t } = useTranslation();
const { days, focusDay, disabled, blockedKey, adding } = strip;
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
estimatedHeight: Math.min(320, days.length * 34 + 8),
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
});
const height = tall ? 'h-10' : 'h-7';
const busy = adding === place.ref;
@@ -91,26 +90,44 @@ function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; str
) : (
buttons
)}
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="max-w-[16rem]">
{days.map((day) => (
<PopoverItem
key={day.id}
icon={null}
label={day.label}
active={day.id === focusDay?.id}
onClick={() => pick(day.id)}
/>
))}
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
<div className="grid grid-cols-7 gap-1">
{days.map((day) => {
const current = day.id === focusDay?.id;
return (
<Tooltip key={day.id} label={day.label} placement="top">
<button
type="button"
onClick={() => pick(day.id)}
aria-label={day.label}
aria-pressed={current}
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
}`}
>
<span className="text-body font-semibold tabular-nums">{day.n}</span>
{day.weekday && (
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
{day.weekday}
</span>
)}
</button>
</Tooltip>
);
})}
</div>
</PopoverListPanel>
</>
);
}
/**
* One place a search found, as a small card: its picture from TREK's photo
* proxy (or its category's icon), its name, the category it most likely
* belongs in, whether it is open now while the trip runs, how far it is from
* the last stop of the day in focus, and one way on: "+ Day 3" for a new
* One place a search found, as a card: its picture from TREK's photo proxy
* with the category it most likely belongs in on it (or that category's icon
* on its colour), its name, address and rating, whether it is open now while
* the trip runs, how far it is from the last stop of the day in focus, and one
* way on: "+ Day 3" for a new
* place, "On map" for one the trip has. A click on the card shows it on the
* map, and resting the pointer on it pins it there for a moment.
*/
@@ -156,8 +173,8 @@ export default function AssistantPlaceCard({
return (
<li
ref={ref}
className={`flex flex-shrink-0 snap-start flex-col overflow-hidden rounded-xl border border-edge-faint bg-surface-card ${
density === 'compact' ? 'w-[168px]' : 'w-[200px]'
className={`group/card flex flex-shrink-0 snap-start flex-col overflow-hidden rounded-2xl border border-edge-faint bg-surface-card shadow-sm transition-shadow hover:shadow-md ${
density === 'compact' ? 'w-[188px]' : 'w-[220px]'
}`}
onMouseEnter={() => strip.hover(place)}
onMouseLeave={() => strip.hover(null)}
@@ -169,36 +186,62 @@ export default function AssistantPlaceCard({
onBlur={() => strip.hover(null)}
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
>
<span className="flex h-[72px] w-full items-center justify-center overflow-hidden bg-surface-secondary">
<span
className="relative block h-24 w-full overflow-hidden bg-surface-secondary"
// Without a picture the category's own colour washes the space, data from the trip.
style={
photo || !category
? undefined
: { background: `color-mix(in srgb, ${category.color} 16%, var(--bg-secondary))` }
}
>
{photo ? (
<img src={photo} alt="" decoding="async" loading="lazy" className="h-full w-full object-cover" />
<img
src={photo}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-cover transition-transform duration-300 group-hover/card:scale-[1.04]"
/>
) : (
// The category's own colour, data from the trip; only the small icon wears it.
<Icon size={20} aria-hidden="true" style={{ color: category?.color ?? 'var(--text-faint)' }} />
)}
</span>
<span className="flex min-w-0 flex-col gap-0.5 px-2.5 pt-2">
<bdi className="truncate text-body font-semibold text-content">{place.name}</bdi>
{(category || place.openNow !== null) && (
<span className="flex min-w-0 items-center gap-2 text-caption text-content-muted">
{category && <bdi className="min-w-0 truncate">{category.name}</bdi>}
{place.openNow === true && (
<span className="inline-flex flex-shrink-0 items-center gap-1">
<span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-success" />
{t('assistant.place.openNow')}
</span>
)}
{place.openNow === false && <span className="flex-shrink-0">{t('assistant.place.closed')}</span>}
<span className="grid h-full w-full place-items-center">
<Icon size={30} aria-hidden="true" style={{ color: category?.color ?? 'var(--text-faint)' }} />
</span>
)}
{distance && (
<span className="truncate text-caption text-content-faint">
{t('assistant.place.fromPrev', { distance })}
{category && photo && (
<span className="absolute start-2 top-2 inline-flex max-w-[75%] items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content shadow-sm">
<Icon size={11} aria-hidden="true" className="flex-shrink-0" style={{ color: category.color }} />
<bdi className="truncate">{category.name}</bdi>
</span>
)}
{place.openNow !== null && (
<span className="absolute bottom-2 end-2 inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content-muted shadow-sm">
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${place.openNow ? 'bg-success' : 'bg-[color:var(--text-faint)]'}`}
/>
{t(place.openNow ? 'assistant.place.openNow' : 'assistant.place.closed')}
</span>
)}
</span>
<span className="flex min-w-0 flex-col gap-1 px-3 pt-2.5">
<bdi className="line-clamp-2 text-body font-semibold leading-snug text-content">{place.name}</bdi>
{category && !photo && <bdi className="truncate text-caption text-content-muted">{category.name}</bdi>}
{place.address && <bdi className="truncate text-caption text-content-muted">{place.address}</bdi>}
{(place.rating !== null || distance) && (
<span className="flex min-w-0 items-center gap-2 text-caption text-content-faint">
{place.rating !== null && (
<span className="inline-flex flex-shrink-0 items-center gap-0.5 font-medium text-content-muted">
<Star size={11} aria-hidden="true" className="fill-current text-warning" />
{place.rating.toFixed(1)}
</span>
)}
{distance && <span className="truncate">{t('assistant.place.fromPrev', { distance })}</span>}
</span>
)}
</span>
</button>
<div className="mt-auto flex min-w-0 px-2.5 pb-2.5 pt-2">{action}</div>
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">{action}</div>
</li>
);
}
@@ -8,7 +8,6 @@
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import {
buildAssignment,
buildCategory,
@@ -17,6 +16,7 @@ import {
buildTrip,
buildUser,
} from '../../../tests/helpers/factories';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { fetchPhoto } from '../../services/photoService';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
@@ -119,12 +119,18 @@ describe('AssistantPlaceStrip', () => {
expect(screen.getByText('1.2 km from the previous stop')).toBeInTheDocument();
});
it('FE-AI-PLACES-002: shows six cards and counts the rest on a tile', () => {
it('FE-AI-PLACES-002: shows every card it was sent and counts what the server left out on a tile', () => {
seed();
const list = Array.from({ length: 8 }, (_, i) => place(`s1:${i}`));
renderStrip({ toolCallId: 's1', places: list, more: 4 });
expect(screen.getAllByRole('button', { name: /^Add to/ })).toHaveLength(6);
expect(screen.getByText('6 more')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /^Add to/ })).toHaveLength(8);
expect(screen.getByText('4 more')).toBeInTheDocument();
});
it('FE-AI-PLACES-009: a place known only by its position shows its category, never a photo of the area', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0', { photoKey: 'coords:41.889000,12.470000' })], more: 0 });
expect(fetchPhoto).not.toHaveBeenCalled();
});
it('FE-AI-PLACES-003: a picture only ever comes from the photo proxy', async () => {
@@ -3,16 +3,18 @@ import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import { prefersReducedMotion } from '../../utils/reducedMotion';
import AssistantPlaceCard from './AssistantPlaceCard';
import { suggestedFromStrip } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
/** Cards the strip shows; the rest are counted on the tile at its end. */
const SHOWN = 6;
/** Cards the strip shows, every one the server sends; what it left out is counted on the tile at its end. */
const SHOWN = 20;
// In the strip's head, never over a card: a press that ends a pixel off still lands on the arrow.
const ARROW =
'absolute top-[52px] z-10 hidden h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full border border-edge-faint bg-surface-card text-content-secondary shadow-dropdown hover:text-content group-hover:flex';
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content disabled:cursor-default disabled:opacity-40 disabled:hover:text-content-secondary';
/** How far the strip has scrolled from either end, with the reading direction in mind. */
function edgesOf(el: HTMLElement): { start: boolean; end: boolean } {
@@ -24,8 +26,9 @@ function edgesOf(el: HTMLElement): { start: boolean; end: boolean } {
/**
* The places a search found, as one row of cards to swipe or scroll through:
* the next card peeks in, so the row says it goes on, and the edges fade
* where there is more. On the desktop two arrows show on hover while the row
* overflows. At most six cards, then a tile counting the rest.
* where there is more. On the desktop a head over the row names it and holds
* two arrows that move it by whole cards, off while that way is the end. Every card the step found, then a
* tile counting what the server left out.
*/
export default function AssistantPlaceStrip({ found, message }: { found: AssistantPlaces; message: AssistantMessage }) {
const { t } = useTranslation();
@@ -55,22 +58,63 @@ export default function AssistantPlaceStrip({ found, message }: { found: Assista
const { hover } = strip;
useEffect(() => () => hover(null), [hover]);
/** Scrolls by most of a view towards the start (-1) or the end (1) of the row. */
/**
* Scrolls by as many whole cards as the view holds, towards the start (-1)
* or the end (1), so the row lands on a card edge instead of fighting its
* snap points half way; smoothly, unless motion is turned down.
*/
const page = (towards: -1 | 1) => {
const el = listRef.current;
if (!el) return;
const card = el?.firstElementChild as HTMLElement | null;
if (!el || !card) return;
const gap = Number.parseFloat(getComputedStyle(el).columnGap) || 0;
const step = card.offsetWidth + gap;
const cards = Math.max(1, Math.floor((el.clientWidth - 32) / step));
const rtl = getComputedStyle(el).direction === 'rtl';
el.scrollBy((rtl ? -towards : towards) * el.clientWidth * 0.8, 0);
el.scrollBy({
left: (rtl ? -towards : towards) * cards * step,
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
};
const total = found.places.length + found.more;
return (
<section aria-label={t('assistant.place.strip')} className="group relative -mx-4">
<section aria-label={t('assistant.place.strip')} className="-mx-4">
{variant === 'window' && (fade.start || fade.end) && (
// The cards are reachable by Tab; the arrows are for the pointer only.
<div className="mb-1.5 flex items-center gap-2 px-4">
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-muted">
{t('assistant.place.strip')}
<span className="ms-1.5 tabular-nums text-content-faint">{total}</span>
</span>
<button
type="button"
tabIndex={-1}
aria-hidden="true"
disabled={!fade.start}
onClick={() => page(-1)}
className={ARROW}
>
<ChevronLeft size={15} className="rtl:-scale-x-100" />
</button>
<button
type="button"
tabIndex={-1}
aria-hidden="true"
disabled={!fade.end}
onClick={() => page(1)}
className={ARROW}
>
<ChevronRight size={15} className="rtl:-scale-x-100" />
</button>
</div>
)}
<ul
ref={listRef}
onScroll={measure}
data-fade-start={fade.start || undefined}
data-fade-end={fade.end || undefined}
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2 overflow-x-auto px-4 pb-1"
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5"
>
{found.places.slice(0, SHOWN).map((place) => (
<AssistantPlaceCard
@@ -81,32 +125,11 @@ export default function AssistantPlaceStrip({ found, message }: { found: Assista
/>
))}
{rest > 0 && (
<li className="flex w-24 flex-shrink-0 snap-start items-center justify-center rounded-xl border border-dashed border-edge-faint px-2 text-center text-caption text-content-muted">
<li className="flex w-24 flex-shrink-0 snap-start items-center justify-center rounded-2xl border border-dashed border-edge-faint px-2 text-center text-caption text-content-muted">
{t('assistant.place.more', { count: rest })}
</li>
)}
</ul>
{variant === 'window' && (
// The cards are reachable by Tab; the arrows are for the pointer only.
<>
{fade.start && (
<button
type="button"
tabIndex={-1}
aria-hidden="true"
onClick={() => page(-1)}
className={`${ARROW} start-2`}
>
<ChevronLeft size={15} className="rtl:-scale-x-100" />
</button>
)}
{fade.end && (
<button type="button" tabIndex={-1} aria-hidden="true" onClick={() => page(1)} className={`${ARROW} end-2`}>
<ChevronRight size={15} className="rtl:-scale-x-100" />
</button>
)}
</>
)}
</section>
);
}
@@ -0,0 +1,257 @@
/**
* FE-AI-POWER-001 to FE-AI-POWER-010
*
* The parts that tie the chat into the planner: the next questions under an
* answer, the waiting changes inside the day list, the week grid of a plan
* over several days, the context menu rows, the trip notes and the part of the
* map a question carries.
*/
import { act, renderHook } from '@testing-library/react';
import type { AssistantChangePreview, AssistantMessage } from '@trek/shared';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import { mapViewOf } from '../Map/assistantViewport';
import AssistantDayPreview from './AssistantDayPreview';
import { followUpsFor } from './assistantFollowUpRules';
import AssistantFollowUps from './AssistantFollowUps';
import type { ChangeItem } from './assistantParts';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
import AssistantWeekOverview from './AssistantWeekOverview';
import { useAssistantMenu } from './useAssistantMenu';
import { useTripMemory } from './useTripMemory';
import { useWeekDays } from './useWeekDays';
const TRIP = 9;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
const SETTINGS = useSettingsStore.getState();
const AUTH = useAuthStore.getState();
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useTripStore.setState(TRIPS, true);
useSettingsStore.setState(SETTINGS, true);
useAuthStore.setState(AUTH, true);
});
const view: AssistantView = { tripId: TRIP, variant: 'window' };
const inView = (node: ReactNode) => <AssistantViewContext.Provider value={view}>{node}</AssistantViewContext.Provider>;
function answer(parts: AssistantMessage['parts'], id = 'a1'): AssistantMessage {
return { id, role: 'assistant', status: 'complete', createdAt: '2026-10-21T08:00:00.000Z', parts };
}
function preview(
toolCallId: string,
dayId: number,
over: Partial<AssistantChangePreview> = {}
): AssistantChangePreview {
return {
toolCallId,
verbKey: 'addPlace',
subject: 'Place',
rows: [],
dayId,
placeIds: [],
undoable: true,
effect: { kind: 'add', dayId, fromDayId: null, placeId: null, assignmentId: null, place: null },
...over,
};
}
function waiting(toolCallId: string, data: AssistantChangePreview): AssistantMessage['parts'] {
return [
{ type: 'tool-call', toolCallId, toolName: 'create_place', args: {}, approval: { id: toolCallId } },
{ type: 'data-trek-change', data },
];
}
function seedChat(messages: AssistantMessage[], extra: Record<string, unknown> = {}) {
useAssistantStore.setState({
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages, ...extra } },
});
}
describe('the next questions', () => {
it('FE-AI-POWER-001: an answer offers what fits what it holds, changes only to someone who may make them', () => {
const ids = (parts: AssistantMessage['parts'], canEdit = true) =>
followUpsFor(answer(parts), canEdit).map((f) => f.id);
expect(ids([{ type: 'data-trek-places', data: { toolCallId: 's', places: [], more: 0 } }])).toContain('cheaper');
expect(ids([{ type: 'data-trek-change', data: preview('c', 1) }])).toContain('nextDay');
expect(ids([{ type: 'data-trek-change', data: preview('c', 1) }], false)).toEqual([]);
expect(ids([{ type: 'text', text: 'Hi' }])).toEqual(['more', 'next']);
expect(ids([])).toEqual([]);
});
it('FE-AI-POWER-002: under the last finished answer a tap sends the question; not while it runs', () => {
const send = vi.fn(async () => {});
const last = answer([{ type: 'text', text: 'Done.' }]);
seedChat([last]);
useAssistantStore.setState({ send });
const { rerender } = render(inView(<AssistantFollowUps message={last} />));
fireEvent.click(screen.getByRole('button', { name: 'Tell me more' }));
expect(send).toHaveBeenCalledWith(TRIP, 'Tell me more');
seedChat([last], { isRunning: true });
rerender(inView(<AssistantFollowUps message={last} />));
expect(screen.queryByRole('button', { name: 'Tell me more' })).toBeNull();
});
});
describe('the day list preview', () => {
it('FE-AI-POWER-003: a waiting add shows dashed on its day, a removal struck, and resting on it lights the change', () => {
useTripStore.setState({ places: [buildPlace({ id: 5, name: 'Louvre' })] });
const add = preview('c1', 3, {
effect: {
kind: 'add',
dayId: 3,
fromDayId: null,
placeId: null,
assignmentId: null,
place: { name: 'Pantheon', lat: 1, lng: 2, categoryId: null },
},
});
const remove = preview('c2', 3, {
effect: { kind: 'remove', dayId: 3, fromDayId: null, placeId: 5, assignmentId: 50, place: null },
});
seedChat([answer([...waiting('c1', add), ...waiting('c2', remove)])]);
render(<AssistantDayPreview tripId={TRIP} dayId={3} />);
expect(screen.getByText('Pantheon')).toBeInTheDocument();
expect(screen.getByText('Louvre')).toHaveClass('line-through');
fireEvent.mouseEnter(screen.getByText('Pantheon'));
expect(useAssistantStore.getState().mapLink?.hoveredId).toBe('c1');
});
it('FE-AI-POWER-004: nothing shows on another day, or once the change was decided', () => {
const add = preview('c1', 3, {
effect: {
kind: 'add',
dayId: 3,
fromDayId: null,
placeId: null,
assignmentId: null,
place: { name: 'Pantheon', lat: 1, lng: 2, categoryId: null },
},
});
seedChat([answer(waiting('c1', add))]);
const { container } = render(<AssistantDayPreview tripId={TRIP} dayId={4} />);
expect(container).toBeEmptyDOMElement();
});
});
describe('the week grid', () => {
const item = (id: string, dayId: number): ChangeItem => ({
toolCallId: id,
preview: preview(id, dayId),
applied: null,
state: 'pending',
});
it('FE-AI-POWER-005: from three days on the changes group by day, in trip order', () => {
useTripStore.setState({
trip: buildTrip({ id: TRIP }),
days: [
buildDay({ id: 1, date: '2026-11-02' }),
buildDay({ id: 2, date: '2026-11-03' }),
buildDay({ id: 3, date: '2026-11-04' }),
],
});
const wrapper = ({ children }: { children: ReactNode }) => inView(children);
const three = renderHook(() => useWeekDays([item('a', 3), item('b', 1), item('c', 2), item('d', 1)]), { wrapper });
expect(three.result.current.map((d) => [d.n, d.ids])).toEqual([
[1, ['b', 'd']],
[2, ['c']],
[3, ['a']],
]);
const two = renderHook(() => useWeekDays([item('a', 1), item('b', 2)]), { wrapper });
expect(two.result.current).toEqual([]);
});
it('FE-AI-POWER-006: a tap leaves a whole day out or takes it back in', () => {
const onToggleDay = vi.fn();
const days = [
{ id: 1, n: 1, weekday: 'Mon', ids: ['a', 'b'] },
{ id: 2, n: 2, weekday: 'Tue', ids: ['c'] },
];
render(<AssistantWeekOverview days={days} excluded={new Set(['c'])} disabled={false} onToggleDay={onToggleDay} />);
const [mon, tue] = screen.getAllByRole('button');
expect(mon).toHaveAttribute('aria-pressed', 'true');
expect(tue).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(mon);
expect(onToggleDay).toHaveBeenCalledWith(['a', 'b'], false);
fireEvent.click(tue);
expect(onToggleDay).toHaveBeenLastCalledWith(['c'], true);
});
});
describe('the context menu rows', () => {
it('FE-AI-POWER-007: none while the assistant cannot run; else they open the chat on the place and ask', () => {
const send = vi.fn(async () => {});
const openWith = vi.fn();
useAssistantStore.setState({ status: null, send, openWith });
const louvre = buildPlace({ id: 5, name: 'Louvre' });
const off = renderHook(() => useAssistantMenu(TRIP));
expect(off.result.current.place(louvre)).toEqual([]);
useAssistantStore.setState({ status: { available: true } as never });
const on = renderHook(() => useAssistantMenu(TRIP));
const rows = on.result.current.place(louvre);
const alternative = rows.find((r) => 'label' in r && r.label === 'Find an alternative');
act(() => (alternative && 'onClick' in alternative ? alternative.onClick() : undefined));
expect(openWith).toHaveBeenCalledWith(TRIP, { placeIds: [5] });
expect(send).toHaveBeenCalledWith(TRIP, expect.stringContaining('Louvre'));
expect(on.result.current.day(3).length).toBeGreaterThan(1);
});
});
describe('the trip notes', () => {
it('FE-AI-POWER-008: notes are kept per trip, and saving empty ones drops the entry', async () => {
useAuthStore.setState({ user: buildUser({ id: 1 }) });
const updateSetting = vi.fn(async () => {});
useSettingsStore.setState({
settings: { ...SETTINGS.settings, assistant_memory: { '2': 'Other' } },
updateSetting,
});
const { result } = renderHook(() => useTripMemory(TRIP));
expect(result.current.has).toBe(false);
await act(async () => void (await result.current.save(' Vegetarian ')));
expect(updateSetting).toHaveBeenCalledWith('assistant_memory', { '2': 'Other', [TRIP]: 'Vegetarian' });
await act(async () => void (await result.current.save(' ')));
expect(updateSetting).toHaveBeenLastCalledWith('assistant_memory', { '2': 'Other' });
});
});
describe('the map a question carries', () => {
it('FE-AI-POWER-009: edges stay on the globe and are rounded to about ten metres', () => {
expect(mapViewOf({ south: 35.123456, west: -190, north: 35.2, east: 139.987654 })).toEqual({
south: 35.1235,
west: -180,
north: 35.2,
east: 139.9877,
});
});
it('FE-AI-POWER-010: a question goes out with the map of its own trip in its focus', async () => {
const send = vi.fn();
useAssistantStore.setState({ mapView: { tripId: TRIP, view: { south: 1, west: 2, north: 3, east: 4 } } });
seedChat([]);
// The request body is built inside the store; read it from the repo call.
const { assistantRepo } = await import('../../repo/assistantRepo');
const spy = vi.spyOn(assistantRepo, 'send').mockImplementation(async (_trip, body) => {
send(body);
throw new Error('stop');
});
await useAssistantStore.getState().send(TRIP, 'What is here?');
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ focus: { view: { south: 1, west: 2, north: 3, east: 4 } } })
);
spy.mockRestore();
});
});
@@ -0,0 +1,65 @@
import { Check, Minus } from 'lucide-react';
import { useTranslation } from '../../i18n';
import type { WeekDay } from './useWeekDays';
/**
* A plan over several days as one grid: a tile per day with how many changes
* it gets, taken in or left out as a whole with one tap. The single changes
* stay below it, folded away until asked for.
*/
export default function AssistantWeekOverview({
days,
excluded,
disabled,
onToggleDay,
}: {
days: readonly WeekDay[];
excluded: ReadonlySet<string>;
disabled: boolean;
onToggleDay: (ids: readonly string[], include: boolean) => void;
}) {
const { t } = useTranslation();
return (
<div
role="group"
aria-label={t('assistant.week.title')}
className="mb-2 grid gap-1.5"
style={{ gridTemplateColumns: `repeat(${Math.min(days.length, 7)}, minmax(0, 1fr))` }}
>
{days.map((day, i) => {
const on = day.ids.filter((id) => !excluded.has(id)).length;
const taken = on > 0;
return (
<button
key={day.id}
type="button"
disabled={disabled}
aria-pressed={taken}
onClick={() => onToggleDay(day.ids, !taken)}
className={`trek-assistant-enter group/day relative flex min-w-0 flex-col items-center gap-0.5 rounded-xl border px-1 py-2 transition-all disabled:cursor-default ${
taken
? 'border-accent bg-accent-subtle text-content shadow-sm hover:-translate-y-px hover:shadow-md'
: 'border-dashed border-edge text-content-faint hover:border-edge-secondary'
}`}
style={{ animationDelay: `${i * 40}ms` }}
>
<span
aria-hidden="true"
className={`absolute end-1 top-1 grid h-3.5 w-3.5 place-items-center rounded-full ${
taken ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'
}`}
>
{taken ? <Check size={9} strokeWidth={3} /> : <Minus size={9} strokeWidth={3} />}
</span>
<span className="text-caption text-content-muted">{day.weekday ?? t('assistant.week.day')}</span>
<span className="text-subtitle font-bold tabular-nums leading-none">{day.n}</span>
<span className={`text-caption tabular-nums ${taken ? 'text-content-secondary' : ''}`}>
{t('assistant.week.changes', { count: taken ? on : day.ids.length })}
</span>
</button>
);
})}
</div>
);
}
@@ -196,6 +196,7 @@ function ChatWindow({
))}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
<AssistantHeader
tripId={trip.id}
thread={thread}
tripTitle={trip.title}
dragProps={frame.handleProps}
+18 -2
View File
@@ -168,6 +168,20 @@
animation: trek-assistant-busy 1.8s ease-in-out infinite;
}
/* The dot beside what the assistant does right now: a soft beat, a halo going out. */
@keyframes trek-assistant-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
}
70%,
100% {
box-shadow: 0 0 0 6px transparent;
}
}
.trek-assistant-pulse {
animation: trek-assistant-pulse 1.4s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.trek-assistant-pop,
.trek-assistant-enter {
@@ -176,7 +190,8 @@
.trek-assistant-settle {
transition: none;
}
.trek-assistant-busy {
.trek-assistant-busy,
.trek-assistant-pulse {
animation: none;
}
.trek-assistant-shimmer {
@@ -196,7 +211,8 @@
[data-reduce-motion] .trek-assistant-settle {
transition: none;
}
[data-reduce-motion] .trek-assistant-busy {
[data-reduce-motion] .trek-assistant-busy,
[data-reduce-motion] .trek-assistant-pulse {
animation: none;
}
[data-reduce-motion] .trek-assistant-shimmer {
@@ -0,0 +1,44 @@
import type { AssistantMessage } from '@trek/shared';
/**
* The next questions an answer suggests, as one tap each: read from what the
* answer holds, not asked from the model, so they cost nothing and come at
* once. Places found ask to narrow them, a plan asks to go on, a forecast asks
* for the other weather. Three at most; the first kind the answer holds wins.
*/
export interface FollowUp {
id: string;
labelKey: string;
}
const PLACES: readonly FollowUp[] = [
{ id: 'cheaper', labelKey: 'assistant.followUp.cheaper' },
{ id: 'closer', labelKey: 'assistant.followUp.closer' },
{ id: 'openNow', labelKey: 'assistant.followUp.openNow' },
];
const PLAN: readonly FollowUp[] = [
{ id: 'nextDay', labelKey: 'assistant.followUp.nextDay' },
{ id: 'relaxed', labelKey: 'assistant.followUp.relaxed' },
{ id: 'dinners', labelKey: 'assistant.followUp.dinners' },
];
const WEATHER: readonly FollowUp[] = [
{ id: 'rain', labelKey: 'assistant.followUp.rain' },
{ id: 'sun', labelKey: 'assistant.followUp.sun' },
];
const GENERAL: readonly FollowUp[] = [
{ id: 'more', labelKey: 'assistant.followUp.more' },
{ id: 'next', labelKey: 'assistant.followUp.next' },
];
/** What an answer offers to ask next; changes are offered only to someone who may make them. */
export function followUpsFor(message: AssistantMessage, canEdit: boolean): readonly FollowUp[] {
const types = new Set(message.parts.map((part) => part.type));
if (canEdit && (types.has('data-trek-change') || types.has('data-trek-applied'))) return PLAN;
if (types.has('data-trek-places')) return PLACES;
if (types.has('data-trek-weather')) return WEATHER;
return types.has('text') ? GENERAL : [];
}
@@ -92,6 +92,10 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
messageKey: 'assistant.error.maxSteps',
action: { kind: 'continue', labelKey: 'assistant.errorAction.continue' },
},
empty_answer: {
messageKey: 'assistant.error.emptyAnswer',
action: { kind: 'continue', labelKey: 'assistant.errorAction.continue' },
},
offline: { messageKey: 'assistant.error.offline', action: null },
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
@@ -1,5 +1,5 @@
/**
* FE-AI-DAYAFTER-001 to FE-AI-DAYAFTER-008
* FE-AI-DAYAFTER-001 to FE-AI-DAYAFTER-009
*
* A day as it would read once a card's waiting changes ran, without React: who
* stays, who joins at the end and who leaves, the way between two stops as a
@@ -10,7 +10,15 @@ import { describe, expect, it } from 'vitest';
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories';
import type { AssignmentsMap } from '../../types';
import { buildDayAfter, changedDays, changePoints, type DayChange, minutesOf } from './dayAfterModel';
import {
buildDayAfter,
changedDays,
changePoints,
type DayChange,
dayIssues,
minutesOf,
stopIssue,
} from './dayAfterModel';
// 0.01° of latitude is 1.112 km: with the detour factor that is 19 minutes on foot, 6 by bike.
const louvre = buildPlace({ id: 10, name: 'Louvre', lat: 48.0, lng: 2.0, duration_minutes: 60 });
@@ -51,7 +59,7 @@ describe('dayAfterModel', () => {
]);
expect(stops[0]?.lead).toBeNull();
// 09:00 plus an hour, 19 minutes on foot, so 161 minutes are left before 13:00.
expect(stops[1]?.lead).toEqual({ travelMinutes: 19, freeMinutes: 161, overlapsWith: null });
expect(stops[1]?.lead).toEqual({ travelMinutes: 19, freeMinutes: 161, overlapsWith: null, shortBy: null });
});
it('FE-AI-DAYAFTER-002: a new place joins at the end, named and placed by the change', () => {
@@ -59,7 +67,7 @@ describe('dayAfterModel', () => {
const { stops } = day(3, [change('c1', pantheon)]);
const last = stops[stops.length - 1];
expect(last).toMatchObject({ key: 'new:c1', name: 'Pantheon', status: 'new', toolCallId: 'c1', time: null });
expect(last?.lead).toEqual({ travelMinutes: 19, freeMinutes: null, overlapsWith: null });
expect(last?.lead).toEqual({ travelMinutes: 19, freeMinutes: null, overlapsWith: null, shortBy: null });
});
it('FE-AI-DAYAFTER-003: a planned place joins by its id; a change with nothing to name it by is left out', () => {
@@ -139,4 +147,15 @@ describe('dayAfterModel', () => {
[48.02, 2.0],
]);
});
it('FE-AI-DAYAFTER-009: a way longer than the time between two stops is too tight, and counts as an issue', () => {
// Louvre until 10:00, then 19 minutes on foot to Orsay, which starts at 10:10: nine minutes short.
const tight = plan();
tight[3] = (tight[3] ?? []).map((a) => (a.id === 101 ? { ...a, assignment_time: '10:10' } : a));
const after = day(3, [], tight);
expect(after.stops[1]?.lead).toMatchObject({ travelMinutes: 19, shortBy: 9 });
expect(after.stops.map(stopIssue)).toEqual([false, true]);
expect(dayIssues(after)).toBe(1);
expect(dayIssues(day(3, []))).toBe(0);
});
});
@@ -35,6 +35,8 @@ interface StopLead {
freeMinutes: number | null;
/** The stop before, when the two take the same time. */
overlapsWith: string | null;
/** Minutes the way there is longer than the time between the two; null when it fits or the times do not say. */
shortBy: number | null;
}
export interface TimelineStop {
@@ -152,10 +154,12 @@ function leadOf(prev: Draft, cur: Draft, defaultMode: string): StopLead {
60
)
: null;
if (cur.start === null || prev.end === null) return { travelMinutes, freeMinutes: null, overlapsWith: null };
if (cur.start < prev.end) return { travelMinutes, freeMinutes: null, overlapsWith: prev.stop.name };
const none = { freeMinutes: null, overlapsWith: null, shortBy: null };
if (cur.start === null || prev.end === null) return { travelMinutes, ...none };
if (cur.start < prev.end) return { travelMinutes, ...none, overlapsWith: prev.stop.name };
const free = cur.start - prev.end - (travelMinutes ?? 0);
return { travelMinutes, freeMinutes: free >= GAP_MIN_MINUTES ? free : null, overlapsWith: null };
if (free < 0) return { travelMinutes, ...none, shortBy: -free };
return { travelMinutes, ...none, freeMinutes: free >= GAP_MIN_MINUTES ? free : null };
}
/**
@@ -230,3 +234,27 @@ export function changedDays(changes: readonly DayChange[]): number[] {
}
return days;
}
/** A leg this long without a time says the day asks a lot of travel. */
const FAR_MINUTES = 60;
/**
* Whether a day still works once the changes ran: stops that take the same
* time, a way there longer than the time between two stops, and legs over an
* hour. Counted over the stops that stay on the day.
*/
export function dayIssues(day: DayAfter): number {
return day.stops.reduce((sum, stop) => {
const lead = stop.lead;
if (stop.status === 'removed' || !lead) return sum;
const far = lead.travelMinutes !== null && lead.travelMinutes >= FAR_MINUTES;
return sum + (lead.overlapsWith || lead.shortBy !== null || far ? 1 : 0);
}, 0);
}
/** Whether a stop is one of those `dayIssues` counts. */
export function stopIssue(stop: TimelineStop): boolean {
const lead = stop.lead;
if (stop.status === 'removed' || !lead) return false;
return !!lead.overlapsWith || lead.shortBy !== null || (lead.travelMinutes ?? 0) >= FAR_MINUTES;
}
@@ -2,20 +2,19 @@ import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useToast } from '../shared/Toast';
/**
* Whether the trip map follows the assistant: pans to where it searches and
* frames the places it suggests. On unless switched off. Behind the switch in
* both AI settings shells, the desktop section and the phone card.
*/
export function useAssistantMapFollow(): { on: boolean; set: (on: boolean) => Promise<void> } {
const on = useSettingsStore((s) => s.settings.assistant_map_follow) !== false;
/** The assistant's own on/off settings; each reads as on unless switched off. */
type AssistantFlag = 'assistant_map_follow' | 'assistant_cost_estimates';
/** One of the assistant's switches, read and saved through the user's settings. */
function useAssistantFlag(key: AssistantFlag): { on: boolean; set: (on: boolean) => Promise<void> } {
const on = useSettingsStore((s) => s.settings[key]) !== false;
const updateSetting = useSettingsStore((s) => s.updateSetting);
const { t } = useTranslation();
const toast = useToast();
const set = async (next: boolean) => {
try {
await updateSetting('assistant_map_follow', next);
await updateSetting(key, next);
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('common.error'));
}
@@ -23,3 +22,21 @@ export function useAssistantMapFollow(): { on: boolean; set: (on: boolean) => Pr
return { on, set };
}
/**
* Whether the trip map follows the assistant: pans to where it searches and
* frames the places it suggests. On unless switched off. Behind the switch in
* both AI settings shells, the desktop section and the phone card.
*/
export function useAssistantMapFollow() {
return useAssistantFlag('assistant_map_follow');
}
/**
* Whether the assistant proposes an estimated budget item along with a place
* that costs money, as one more change card to apply or leave. On unless
* switched off; the server reads the same setting.
*/
export function useAssistantCostEstimates() {
return useAssistantFlag('assistant_cost_estimates');
}
@@ -0,0 +1,70 @@
import type { AssistantFocus } from '@trek/shared';
import { CalendarCheck, CloudRain, type LucideIcon, Shuffle, Sparkles, UtensilsCrossed } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { useAssistantStore } from '../../store/assistantStore';
import type { Place } from '../../types';
/** One row of a context menu, or the line before the assistant's rows. */
type AssistantMenuEntry = { label: string; icon: LucideIcon; onClick: () => void } | { divider: true };
/**
* The assistant's rows in the planner's context menus, so a question starts
* where the user already is: a right click on a place asks about it, finds
* something else in its place, or a restaurant near it; one on a day plans it
* or makes a plan for rain. Each opens the chat with the place or day as its
* focus and sends the question at once. Empty while the assistant cannot run
* for this user, so the menus look as they always did.
*/
export function useAssistantMenu(tripId: number | null) {
const { t } = useTranslation();
const available = useAssistantStore((s) => s.status?.available === true);
const openWith = useAssistantStore((s) => s.openWith);
const send = useAssistantStore((s) => s.send);
const on = available && tripId !== null;
const ask = (focus: AssistantFocus, text?: string) => {
if (tripId === null) return;
openWith(tripId, focus);
if (text) void send(tripId, text);
};
const place = (target: Place): AssistantMenuEntry[] => {
if (!on) return [];
const focus = { placeIds: [target.id] };
return [
{ divider: true },
{ label: t('assistant.context.ask'), icon: Sparkles, onClick: () => ask(focus) },
{
label: t('assistant.menu.alternative'),
icon: Shuffle,
onClick: () => ask(focus, t('assistant.menu.alternativeAsk', { place: target.name })),
},
{
label: t('assistant.menu.foodNearby'),
icon: UtensilsCrossed,
onClick: () => ask(focus, t('assistant.menu.foodNearbyAsk', { place: target.name })),
},
];
};
const day = (dayId: number): AssistantMenuEntry[] => {
if (!on) return [];
const focus = { dayId };
return [
{ divider: true },
{
label: t('assistant.menu.planDay'),
icon: CalendarCheck,
onClick: () => ask(focus, t('assistant.menu.planDayAsk')),
},
{
label: t('assistant.menu.rainPlan'),
icon: CloudRain,
onClick: () => ask(focus, t('assistant.menu.rainPlanAsk')),
},
];
};
return { place, day };
}
@@ -22,6 +22,14 @@ interface StripDay {
/** Its place in the trip, 1 for the first day: "+ Day 3". */
n: number;
label: string;
/** Its weekday, short ("Sat"), for the day grid; null for a day without a date. */
weekday: string | null;
}
/** The short weekday of a `YYYY-MM-DD` date, read as that calendar day wherever the reader is. */
function weekdayOf(date: string | null | undefined, locale: string): string | null {
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
return new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`));
}
/** The trip category a found place most likely belongs in, for its line and its icon. */
@@ -89,7 +97,12 @@ export function useAssistantPlaceStrip() {
const stripDays = useMemo(
(): StripDay[] =>
(days ?? []).map((day, index) => ({ id: day.id, n: index + 1, label: dayLabel(day, index, t, locale) })),
(days ?? []).map((day, index) => ({
id: day.id,
n: index + 1,
label: dayLabel(day, index, t, locale),
weekday: weekdayOf(day.date, locale),
})),
[days, t, locale]
);
const focusDay = stripDays.find((d) => d.id === focusDayId) ?? null;
@@ -7,6 +7,16 @@ import { useAuthStore } from '../../store/authStore';
/** The only pictures a found place shows: TREK's own photo proxy, never an address from the search. */
const PROXY_PREFIX = '/api/maps/place-photo/';
/**
* A key the proxy can find this very place's photo by. A place known only by
* its position (`coords:`, what a search of the open map data finds) would get
* a picture of its surroundings, the same street scene for every restaurant
* in a district; such a card shows its category instead.
*/
function photoKeyOf(place: AssistantFoundPlace): string | null {
return place.photoKey && !place.photoKey.startsWith('coords:') ? place.photoKey : null;
}
/** A picture TREK's proxy serves, or null for anything else. */
function proxiedPhoto(url: string | null | undefined): string | null {
return url?.startsWith(PROXY_PREFIX) ? url : null;
@@ -18,11 +28,12 @@ function proxiedPhoto(url: string | null | undefined): string | null {
* provider, and a strip holds several. The answer goes through the shared
* photo cache and its limit on parallel requests, so a place the planner shows
* too is fetched once. Null while it loads, when there is none, when photos
* are off, and for any answer that is not the proxy's own address.
* are off, for a place known only by its position, and for any answer that is
* not the proxy's own address.
*/
export function useFoundPlacePhoto(place: AssistantFoundPlace, ref: RefObject<Element | null>): string | null {
const enabled = useAuthStore((s) => s.placesPhotosEnabled);
const key = place.photoKey;
const key = photoKeyOf(place);
const [src, setSrc] = useState<string | null>(() => (key ? proxiedPhoto(getCached(key)?.photoUrl) : null));
const [visible, setVisible] = useState(false);
@@ -0,0 +1,38 @@
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useToast } from '../shared/Toast';
/** As long as the server reads a trip's notes; more is cut there. */
export const MAX_MEMORY_CHARS = 2_000;
/**
* What the user keeps for the assistant to mind on one trip ("vegetarian, no
* museums"): read from and saved to their settings, one entry per trip, so it
* follows them to every device. Every question on the trip carries it. The
* same for the window and the phone.
*/
export function useTripMemory(tripId: number) {
const { t } = useTranslation();
const toast = useToast();
const all = useSettingsStore((s) => s.settings.assistant_memory);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const text = all?.[String(tripId)] ?? '';
/** Saves the notes; empty ones are dropped rather than kept as a blank entry. */
const save = async (next: string): Promise<boolean> => {
const trimmed = next.trim().slice(0, MAX_MEMORY_CHARS);
const rest = { ...(all ?? {}) };
if (trimmed) rest[String(tripId)] = trimmed;
else delete rest[String(tripId)];
try {
await updateSetting('assistant_memory', rest);
toast.success(t('assistant.memory.saved'));
return true;
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('common.error'));
return false;
}
};
return { text, has: text.trim().length > 0, save };
}
@@ -0,0 +1,48 @@
import { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { useTripStore } from '../../store/tripStore';
import type { ChangeItem } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
/** The day a waiting change lands on: where its effect puts it, else the day its card names. */
function dayOf(item: ChangeItem): number | null {
return item.preview?.effect?.dayId ?? item.preview?.dayId ?? null;
}
export interface WeekDay {
id: number;
n: number;
weekday: string | null;
ids: string[];
}
/** A plan this wide reads as a week: the overview shows from three days on. */
const WEEK_FROM_DAYS = 3;
/**
* The days a card's waiting changes land on, in trip order, each with its
* changes; empty for a plan narrower than a week view.
*/
export function useWeekDays(pending: readonly ChangeItem[]): WeekDay[] {
const { locale } = useTranslation();
const { tripId } = useAssistantView();
const days = useTripStore((s) => (s.trip?.id === tripId ? s.days : null));
return useMemo(() => {
if (!days) return [];
const byDay = new Map<number, string[]>();
for (const item of pending) {
const id = dayOf(item);
if (id !== null) byDay.set(id, [...(byDay.get(id) ?? []), item.toolCallId]);
}
if (byDay.size < WEEK_FROM_DAYS) return [];
const weekdayOf = (date: string | null | undefined) =>
date && /^\d{4}-\d{2}-\d{2}$/.test(date)
? new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`))
: null;
return days.flatMap((day, index) => {
const ids = byDay.get(day.id);
return ids ? [{ id: day.id, n: index + 1, weekday: weekdayOf(day.date), ids }] : [];
});
}, [days, pending, locale]);
}
@@ -2,6 +2,7 @@ import type { ViewportPadding } from '../../utils/mapViewport';
import { useAssistantMarkerClasses } from './assistantCamera';
import { AssistantCameraController } from './AssistantCameraController';
import { AssistantGhostLayer } from './AssistantGhostLayer';
import { useReportViewLeaflet } from './assistantViewport';
import type { AssistantMapProps } from './mapViewContract';
interface AssistantMapLeafletProps {
@@ -15,10 +16,12 @@ interface AssistantMapLeafletProps {
/**
* Everything the assistant does on the Leaflet map, mounted inside the map
* container as one child, so MapView only hands its props on: the ghosts,
* the camera, and the pop and pulse on the real pins.
* the camera, the pop and pulse on the real pins, and the part of the map
* the assistant is told is on screen.
*/
export function AssistantMapLeaflet({ assistant, padding, markerEl }: AssistantMapLeafletProps) {
useAssistantMarkerClasses(markerEl, assistant.poppedPlaceIds, assistant.assistantPulsePlaceId);
useReportViewLeaflet();
return (
<>
<AssistantGhostLayer
@@ -0,0 +1,91 @@
import type { AssistantMapView } from '@trek/shared';
import { useEffect } from 'react';
import { useMap } from 'react-leaflet';
import { useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
/**
* The part of the planner's map on screen, told to the assistant after every
* move, so a question about "here" needs no place named. Both renderers report
* through this module; the assistant sends it with the next question only for
* the trip it was taken on.
*/
interface Edges {
south: number;
west: number;
north: number;
east: number;
}
const clamp = (value: number, limit: number) => Math.max(-limit, Math.min(limit, value));
const round = (value: number) => Math.round(value * 1e4) / 1e4;
/** The edges as the contract takes them: inside the globe (a wrapped map runs past ±180) and to about ten metres. */
export function mapViewOf(edges: Edges): AssistantMapView {
return {
south: round(clamp(edges.south, 90)),
west: round(clamp(edges.west, 180)),
north: round(clamp(edges.north, 90)),
east: round(clamp(edges.east, 180)),
};
}
function report(tripId: number | null, edges: Edges | null): void {
const set = useAssistantStore.getState().setMapView;
// A map without a size yet (hidden, or still laying out) has no edges to speak of.
const usable = edges && Object.values(edges).every(Number.isFinite) && edges.north > edges.south;
set(tripId !== null && usable ? { tripId, view: mapViewOf(edges) } : null);
}
/** Leaflet: read from inside the map container, after each move and once on mount. */
export function useReportViewLeaflet(): void {
const map = useMap();
const tripId = useTripStore((s) => s.trip?.id ?? null);
useEffect(() => {
const read = () => {
const b = map.getBounds();
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
};
read();
map.on('moveend', read);
return () => {
map.off('moveend', read);
report(null, null);
};
}, [map, tripId]);
}
/** What the GL map offers for it; mapbox-gl and maplibre-gl alike. */
interface ViewMap {
getBounds: () => {
getSouth: () => number;
getWest: () => number;
getNorth: () => number;
getEast: () => number;
} | null;
on: (type: 'moveend', listener: () => void) => unknown;
off: (type: 'moveend', listener: () => void) => unknown;
}
/** GL: the same, once the map is ready. */
export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady: boolean): void {
const tripId = useTripStore((s) => s.trip?.id ?? null);
useEffect(() => {
const map = mapRef.current as Partial<ViewMap> | null;
if (!mapReady || !map?.on || !map.off || !map.getBounds) return;
const { on, off, getBounds } = { on: map.on.bind(map), off: map.off.bind(map), getBounds: map.getBounds.bind(map) };
const read = () => {
const b = getBounds();
if (typeof b?.getSouth !== 'function') return;
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
};
read();
on('moveend', read);
return () => {
off('moveend', read);
report(null, null);
};
}, [mapRef, mapReady, tripId]);
}
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import type { ViewportPadding } from '../../utils/mapViewport';
import { useAssistantMarkerClasses } from './assistantCamera';
import { PLACE_POP_CLASS, PLACE_PULSE_CLASS } from './assistantGhostMarker';
import { useReportViewGL } from './assistantViewport';
import type { AssistantMapProps } from './mapViewContract';
import { useAssistantCameraGL, type CameraEngine, type CameraMap } from './useAssistantCameraGL';
import { useAssistantGhostMarkersGL, type GhostMap } from './useAssistantGhostMarkersGL';
@@ -65,4 +66,5 @@ export function useAssistantMapGL<Pin extends PinGL>({
useAssistantCameraGL({ mapRef, mapReady, gl, camera: assistant.assistantCamera, padding });
const markerEl = useCallback((id: number) => markersRef.current.get(id)?.el ?? null, [markersRef]);
useAssistantMarkerClasses(markerEl, assistant.poppedPlaceIds, assistant.assistantPulsePlaceId);
useReportViewGL(mapRef, mapReady);
}
@@ -20,6 +20,8 @@ import GoogleMapsIcon from '../shared/GoogleMapsIcon'
import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import { useAssistantMenu } from '../Assistant/useAssistantMenu'
import AssistantDayPreview from '../Assistant/AssistantDayPreview'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
@@ -1252,16 +1254,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
isMobile,
} = withDayPlanDefaults(props)
const onClearDay = props.onClearDay
// Below lg the plan and the places sit in separate tabs, so there is nothing
// to drag between. A coarse pointer is no longer a reason of its own: tablets
// reach the same drag through a long press (#1616).
// Below lg plan and places sit in separate tabs; tablets drag by long press (#1616).
const dragDisabled = isMobile
const ctxMenu = useContextMenu()
const ai = useAssistantMenu(tripId)
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const can = useCanDo()
const canEditDays = can('day_edit', trip)
// Editing or deleting the place itself is a place right; taking it off the
// day stays a day right (#2446).
// Editing or deleting the place is a place right; taking it off the day a day right (#2446).
const canEditPlaces = can('place_edit', trip)
// The calendar subscription hands out a link that reads the trip without an
// account, so it sits behind the same permission as the public share link.
@@ -1578,7 +1578,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
].filter(Boolean)
// Right-click on the head: what can be added, and emptying the day (#2470).
const dayMenuItems = [
...addItems,
...addItems, ...ai.day(day.id),
...(onClearDay && da.length > 0 ? [
{ divider: true },
{ label: t('dayplan.clearDay'), icon: Eraser, danger: true, onClick: () => onClearDay(day.id) },
@@ -1708,8 +1708,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<div
data-dp="day-body"
className="border-t border-edge-faint px-1.5 pb-1.5 pt-1.5"
// The activity list — the largest region and the one behind the
// densest text, so its tint is the faintest of the three.
// The activity list, the largest region behind the densest text: the faintest tint.
style={dayBodyStyle(dayTintBackground(dayTint, 'activity', '--day-tint-activity'))}
onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
@@ -1870,7 +1869,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
},
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => openSaveToCollection(place, !!isTourPlace, tripId) },
{ divider: true },
...ai.place(place), { divider: true },
canEditPlaces &&
onDeletePlace &&
!(toursEnabled && (props.tourPlaceIds?.has(place.id) || place.tour_place_id === place.id)) && {
@@ -2530,7 +2529,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</Tooltip>
) : connector
})()}
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
<AssistantDayPreview tripId={tripId} dayId={day.id} />{/* The assistant's waiting changes, then the drop zone at the list end */}
<div
style={{ minHeight: 12, padding: '2px 8px' }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }}
@@ -18,6 +18,7 @@ import { placeMatchesSearch } from '../../utils/placeSearch'
import { matchesCategoryFilter, matchesPlacesFilter } from '../../utils/placesFilter'
import { safeHttpUrl } from '../../utils/safeUrl'
import { plannedPlaceIds, plannedPlaceIdsForDay, type PlannedAccommodation } from '../../utils/plannedPlaces'
import { useAssistantMenu } from '../Assistant/useAssistantMenu'
import type { MenuEntry } from './planParts'
import { useListImport, type ListImportProvider } from './useListImport'
import { usePlacesPool, type PlacesFilter } from './usePlacesPool'
@@ -93,6 +94,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const { t, language } = useTranslation()
const toast = useToast()
const ctxMenu = useContextMenu()
const ai = useAssistantMenu(tripId)
const trip = useTripStore((s) => s.trip)
// A booking plans the place it points at, so the pool has to see them (#2072).
const reservations = useTripStore((s) => s.reservations)
@@ -276,11 +278,12 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
!!website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(website, '_blank', 'noopener,noreferrer') },
!!googleMapsUrl && { label: t('inspector.google'), icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
...ai.place(place),
{ divider: true },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
]
return entries.filter((entry): entry is MenuEntry => entry !== false)
}, [canEditPlaces, collectionsEnabled, t, onEditPlace, onAssignToDay, onDeletePlace])
}, [canEditPlaces, collectionsEnabled, t, onEditPlace, onAssignToDay, onDeletePlace, ai])
// The day is read when the menu opens, so it stays stable across day switches.
const openCtxMenu = ctxMenu.open
@@ -37,8 +37,10 @@ export default function IntegrationsTab(): React.ReactElement {
own key, and there nobody does. */}
{/* The assistant in TREK, with the same managed rule as AI parsing: there the
operator decides which model the install talks to. */}
{S.aiAssistantEnabled && !managed && <AiConnectionSection />}
{S.llmEnabled && !managed && <LlmConnectionSection />}
{S.aiConnectionEnabled && !managed && (
<AiConnectionSection assistant={S.aiAssistantEnabled} parsing={S.llmEnabled} />
)}
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
{/* Above MCP on purpose: an API key needs no addon, and someone looking for
one should not have to read past a section about AI assistants. */}
<ApiKeysSection />
@@ -54,11 +56,11 @@ export default function IntegrationsTab(): React.ReactElement {
function useIntegrations() {
const { t, locale } = useTranslation()
const toast = useToast()
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, dawarichEnabled,
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
wandererEnabled,
managed, ...mcp,
}
@@ -2,23 +2,23 @@ import type { AiConnectionKind, AiConnectionView } from '@trek/shared';
import { Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../Assistant/useAssistantMapFollow';
import ConfirmDialog from '../../shared/ConfirmDialog';
import CustomSelect from '../../shared/CustomSelect';
import { fs } from '../../shared/DialogShell';
import { useAssistantMapFollow } from '../../Assistant/useAssistantMapFollow';
import Section from '../Section';
import {
SettingRow,
SettingRows,
SETTINGS_BUTTON,
SETTINGS_BUTTON_DANGER,
SETTINGS_BUTTON_PRIMARY,
SettingRow,
SettingRows,
SettingsHint,
StatusPill,
} from '../settingsKit';
import ToggleSwitch from '../ToggleSwitch';
import { aiSectionText, CONNECTION_STATUS } from './aiConnectModel';
import AiConnectPanel, { ProviderMark } from './AiConnectPanel';
import { CONNECTION_STATUS } from './aiConnectModel';
import { useAiConnections } from './useAiConnections';
type AiConnections = ReturnType<typeof useAiConnections>;
@@ -173,6 +173,7 @@ function InstanceConnection({ S }: { S: AiConnections }) {
function AssistantBehaviour() {
const { t } = useTranslation();
const follow = useAssistantMapFollow();
const costs = useAssistantCostEstimates();
return (
<SettingRows>
<SettingRow
@@ -186,26 +187,45 @@ function AssistantBehaviour() {
/>
}
/>
<SettingRow
label={t('assistant.settings.costEstimates')}
hint={t('assistant.settings.costEstimatesHint')}
control={
<ToggleSwitch
on={costs.on}
onToggle={() => void costs.set(!costs.on)}
label={t('assistant.settings.costEstimates')}
/>
}
/>
</SettingRows>
);
}
/**
* Settings → Integrations → AI assistant: connect a provider, see which one
* answers, pick its model, leave again. The only place a user sets the
* assistant up; once it works, one button takes them to it.
* Settings → Integrations → the user's AI connection: connect a provider, see
* which one answers, pick its model, leave again. The assistant runs on it and
* AI parsing reads bookings with it, so it shows while either addon is on and
* says which it serves.
*/
export default function AiConnectionSection(): React.ReactElement | null {
export default function AiConnectionSection({
assistant = true,
parsing = false,
}: {
assistant?: boolean;
parsing?: boolean;
}): React.ReactElement | null {
const { t } = useTranslation();
const S = useAiConnections();
const text = aiSectionText(assistant, parsing);
if (S.view === 'hidden') return null;
const own = S.own;
return (
<Section
title={t('assistant.connect.title')}
title={t(text.titleKey)}
icon={Sparkles}
hint={t('assistant.connect.description')}
hint={t(text.hintKey)}
badge={
own ? (
<StatusPill tone={CONNECTION_STATUS[own.status].tone}>{t(CONNECTION_STATUS[own.status].labelKey)}</StatusPill>
@@ -219,7 +239,7 @@ export default function AiConnectionSection(): React.ReactElement | null {
)}
{S.view === 'own' && own && <OwnConnection S={S} own={own} />}
{S.view === 'instance' && <InstanceConnection S={S} />}
{(S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
{assistant && (S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
{S.view === 'adminOnly' && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
{S.panel && (
@@ -121,3 +121,15 @@ export function httpHostOf(raw: string): string {
export function svgDataUrl(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
/**
* What a user's AI connection serves, as its section's title and line: the
* assistant, AI parsing (booking import reads with the same connection), or both.
*/
export function aiSectionText(assistant: boolean, parsing: boolean): { titleKey: string; hintKey: string } {
if (!assistant) return { titleKey: 'settings.aiParsing.title', hintKey: 'assistant.connect.descriptionParsingOnly' };
return {
titleKey: 'assistant.connect.title',
hintKey: parsing ? 'assistant.connect.descriptionWithParsing' : 'assistant.connect.description',
};
}
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useAddonStore } from '../../store/addonStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
/**
* Which integrations the settings shells offer: the addon flags behind each
@@ -22,6 +23,11 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean })
const dawarichEnabled = addonEnabled('dawarich');
// The wanderer connection feeds the Tours workspace, while the admin allows it there.
const wandererEnabled = useAddonStore((s) => s.toursWanderer);
// The AI connection serves the assistant and AI parsing alike.
const aiConnectionEnabled = aiAssistantEnabled || llmEnabled;
// A personal parsing model saved before parsing read with the AI connection;
// its form stays for whoever has one, and is no longer offered to anyone else.
const ownParsing = useSettingsStore((s) => !!(s.settings.llm_provider && s.settings.llm_model));
const hasIntegrations =
memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled || aiAssistantEnabled || dawarichEnabled;
@@ -35,6 +41,8 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean })
airtrailEnabled,
llmEnabled,
aiAssistantEnabled,
aiConnectionEnabled,
ownParsing,
dawarichEnabled,
wandererEnabled,
hasIntegrations,