Compare commits

...

20 Commits

Author SHA1 Message Date
Maurice 8a8ce4013b fix(assistant): green CI round after the shared ChatGPT plan
- Tests that still knew the ChatGPT sign-in as personal only follow the
  admin's shared plan: the service takes the caller with their role, the
  admin tiles show ChatGPT, the controller tests cover the admin routes.
- The table refused an instance ChatGPT connection with a CHECK, so
  sharing the plan failed. A new migration rebuilds ai_connections
  without that check; the shipped migration stays as it was.
- useChatgptConnect grew past the function size limit: reading the
  clipboard and noticing the return to the tab are helpers of their own.
- The file link factory hands its overrides over apart from the row,
  like every other factory, which ends the deep type instantiation.
- Knip: exports only their own file used are private again, and the
  booking cap reads MAX_WRITES_PER_STEP instead of an alias.
- lint:i18n-keys reaches the tools' own action names through a key
  builder and the connection hints through literal keys.
- MMehrSheet stays in the assistant hints pair only.
2026-10-21 21:40:48 +00:00
Maurice 4abab7a777 feat(assistant): read the conversation from Anthropic's prompt cache
The Anthropic request marked only the instructions and the trip card for
the prompt cache, so every step of a turn paid the whole history again.
It now also marks the end of the history, which the next step starts
from, and the end of what came before the user's question, which every
step of the turn sends unchanged, within the four marks a request takes.

Every provider now reports how much of the input it read from its cache
(Anthropic cache_read_input_tokens, OpenAI cached_tokens), the turn adds
it up, and the usage log line says it, so the saving can be measured.
2026-10-21 21:26:21 +00:00
Maurice f8dc7ad011 fix(assistant): list the admin ChatGPT routes a managed install withholds
The boot check refuses a @ManagedForbidden route the allow list does not
name; the two new admin routes for a shared ChatGPT sign-in were missing.
2026-10-21 23:02:00 +02:00
Maurice 680c52dc85 feat(assistant): an admin can share their ChatGPT plan with everyone
The connection for everyone can now also be the admin's own ChatGPT sign-in.
Whether a plan may be shared that way is the self-hoster's call under their
plan and OpenAI's terms; the form says what it means before the sign-in.
The sign-in starts on the admin route and finishes on the shared one, which
refuses a sign-in for everyone to anyone who is not an admin. The ChatGPT
return reads the clipboard only once the paste handler exists.
2026-10-21 22:57:40 +02:00
Maurice e3d40ddcf6 feat(assistant): the ChatGPT sign-in in a small window, the copied address taken on return
- The sign-in opens in a small window over TREK instead of a new tab.
- Step 2 shows the address the window ends on, so the error page there is
  expected rather than alarming.
- Back in TREK the copied address is read off the clipboard where the browser
  allows it and the sign-in finishes; else the field has the cursor.
- The token and OAuth client factories hand their columns over apart from
  the row, as the others do (TS2589).
2026-10-21 22:50:41 +02:00
Maurice 00b50e280f revert(assistant): drop the ChatGPT relay, open-source apps keep the loopback sign-in
OpenAI offers a fixed web redirect for Sign in with ChatGPT only to selected
commercial partners; open-source apps sign in through the 127.0.0.1 loopback
callback. The relay could never be switched on, so it goes again.
2026-10-21 22:49:01 +02:00
Maurice f8e29dd218 feat(assistant): Sign in with ChatGPT can come back to TREK by itself
Once the TREK project has a client id registered with OpenAI, the sign-in
goes out with the relay page as its redirect address and the install's own
settings in its state; the relay sends the browser back there and the
settings finish the sign-in, nothing to paste. The relay sees the code but
cannot redeem it without the install's PKCE verifier. Without the project's
client id, or for an install without an https address, the loopback page
and the pasted address stay as they are.

relay/chatgpt-callback/index.html is the page to publish.
2026-10-21 22:43:44 +02:00
Maurice 2fbff51299 feat(assistant): change cards in the style of the planner's day list
A waiting change is one row as in the day list: a round choice in front,
what it touches in bold, a faint line with what happens and when. No switch,
nothing to fold open, no day band: the planner's day list already shows the
suggestion among the day's stops. Whether the days still fit is one pill in
the card's head, with what to look at on hover.

Also mends the comment the rebase onto dev split in json-columns.ts.
2026-10-21 22:39:43 +02:00
Maurice 55c6bad295 fix(assistant): readable cards for any tool, and the CI round
- A card for a tool without one of its own names what it works on: the place
  of an assignment, the day, the note, the booking; its arguments sit under
  the labels the other cards use, a new order reads as the names in order.
  28 frequent tools carry their own action name in every language.
- The server's own steps without a card still refuse, as before.
- Tests: the rate limit is asked one request after another, the mention list
  closes on the caret move a browser reports, the place factory hands its
  category over as a plain id, eleven files formatted.
2026-10-21 22:29:59 +02:00
Maurice 858c77422c i18n(fr): translate the road trip domain name 2026-10-21 22:29:59 +02:00
Maurice e5f1f573c5 feat(assistant): every MCP tool of TREK, the core first and the addons on request
The assistant offered 25 hand-picked tools. It now offers every tool the MCP
registry holds, but a short denylist with a reason per tool (deleting the
trip, share, invite and feed links, membership changes, fetching an address
the model picks, documents to the provider, instance-wide categories and
holidays). The scopes widen to every addon but trips:delete, the share
scopes, file contents and plugins.

- A tool annotated read-only runs at once, every other one waits as a change
  card; reads that fetch a planted address wait too.
- A turn starts with the trip's core tools; use_addon_tools loads tours, road
  trip, journey, vacay, atlas, collections, trip chat, files, account or
  plugin tools for the rest of the turn.
- A writing tool without a card of its own gets one from its arguments:
  add, change or remove, the part of TREK it works in, the trip's days,
  places and people named.
- The boot check fails on a tool that shares, invites or publishes and is
  neither denied nor known to only take back.
2026-10-21 22:29:59 +02:00
Maurice b721234249 chore(assistant): mark the step store and the model pick as independent writes
lint:tx counted the three step writes of the store and the ChatGPT sign-in
renewal before a model pick as one sequence. Each stands on its own.
2026-10-21 22:29:58 +02:00
Maurice a1f97ba548 style: format two ai-connections files 2026-10-21 22:29:58 +02:00
Maurice 77ad0a06d0 test: factories hand their overrides over apart from the row
The assistant tables gave Users three more relations, and a spread of
overrides checked against RequiredEntityData now ran tsc past its
instantiation depth (TS2589). Every factory passes its overrides as
createRow's own argument, the way rows.ts documents it.
2026-10-21 22:29:58 +02:00
Maurice ea7120e141 fix(assistant): the found places of every search in one strip
An answer that searched several times showed only its last search, about
ten places. The strip now holds the places of all its searches, each once
(the same Google or OSM id, or the same name at the same spot), and shows
up to 60.
2026-10-21 22:29:58 +02:00
Maurice 7e563e1170 fix(assistant): camera listens on the canvas container, plugin facts regenerated
- the assistant's GL camera reads the user's own moves from the map's canvas
  container, which both engines have and which leaves marker clicks out, so
  the GL map test needs no extra mock and stays at its size
- plugin-sdk host facts list the ai_assistant addon
2026-10-21 22:29:58 +02:00
Maurice 3c3a228737 fix(assistant): lint, types and tests for the CI round
- agent loop and decisions without await in loops: steps as recursion,
  calls and applied changes in order through one promise chain, the
  independent reads and claims in parallel; part mapping in its own helpers
- place card, header and weather strip without refs or components created
  in render; long hooks and components split; state reset during render
- trip notes only in a turn's tool session, never an undo's; map view
  reported only while the assistant can run
- place strip scrolls through the strip's own smooth scroll, no physical side
- tests follow the raised limits and the new card fields; toasts read through
  a mocked useToast instead of a window global; baselines lowered where the
  counts fell
2026-10-21 22:29:58 +02:00
Maurice b6f7488591 fix(assistant): green CI round, trip notes the assistant can keep, calmer change cards
- the assistant keeps facts for a trip (remember_for_trip) as a change card,
  applied into the user's trip notes; existing expenses can be changed
- change card fields as a few icon chips with the rest folded under Details,
  the day band marks only what changes, the week grid as compact tiles
- no map button on receipts of things that have no place on the map
- tests follow the composer's accessible name, the receipt's status icon and
  the GL map's container; the hanging stream helper honours an early abort
- Postgres probe: qualified columns in the upsert updates; entities restored
  to the generator's output; strict ratchet reads the SDK client as untyped
2026-10-21 22:29:57 +02:00
Maurice 26df56d76c 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.
2026-10-21 22:29:57 +02:00
Maurice 214984f398 feat(assistant): trip assistant chat with your own AI connection
Adds an in-app assistant that reads and plans the open trip. Users connect
their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or
OpenRouter key) or use the one the admin set up, including a custom
OpenAI-compatible server. It is an addon and only shows while a connection
is usable.

The chat runs on the existing MCP tools, pinned to the trip. Every change is
held as a change card the user applies or discards, and applied changes can
be undone. On the desktop it is a floating window that can be moved and
resized; on the phone it is fullscreen. Search results show as place cards
that can go onto a day, weather lookups as a strip, proposed places as
ghost markers on the map with a before/after switch, and an answer can be
shared into the trip chat. Booking files can be dropped in and are read
into change cards.
2026-10-21 22:29:56 +02:00
565 changed files with 59363 additions and 1474 deletions
+1
View File
@@ -46,6 +46,7 @@
"predev": "node scripts/patch-maplibre.mjs"
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 874,
"@typescript-eslint/no-misused-promises": 870,
"no-console": 88,
"complexity": 230,
"@typescript-eslint/no-explicit-any": 147,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1946,
"@typescript-eslint/no-non-null-assertion": 1945,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+28 -3
View File
@@ -16,12 +16,17 @@
"feature": "Addon manager",
"hooks": [
"src/components/Admin/addons/addonModel.ts",
"src/components/Admin/addons/useAddonManager.ts",
"src/components/Admin/addons/useLlmParsingConfig.ts"
"src/components/Admin/addons/useAddonManager.ts"
],
"desktop": ["src/components/Admin/AddonManager.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
},
{
"feature": "Addon manager: AI tile",
"hooks": ["src/components/Admin/addons/useAiAddonTile.ts"],
"desktop": ["src/components/Admin/addons/AiAddonTile.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
},
{
"feature": "Admin notification preference matrix",
"hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"],
@@ -58,6 +63,21 @@
"desktop": ["src/pages/admin/AdminUsersTab.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminUsersSection.tsx"]
},
{
"feature": "AI assistant chat",
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
},
{
"feature": "Assistant hints",
"hooks": ["src/components/Assistant/useAssistantHints.ts"],
"desktop": ["src/components/Assistant/AssistantHeader.tsx", "src/components/Assistant/AssistantLauncher.tsx"],
"mobile": [
"src/mobile/screens/trip/assistant/MAssistantScreen.tsx",
"src/mobile/screens/trip/sheets/MMehrSheet.tsx"
]
},
{
"feature": "Atlas country/region action sheet",
"hooks": ["src/pages/atlas/useAtlasCountryActions.ts"],
@@ -652,6 +672,12 @@
"desktop": ["src/components/Settings/DisplaySettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsGeneral.tsx"]
},
{
"feature": "Settings / Integrations / AI assistant",
"hooks": ["src/components/Settings/ai/useAiConnections.ts"],
"desktop": ["src/components/Settings/ai/AiConnectionSection.tsx"],
"mobile": ["src/mobile/screens/settings/MAiConnectionSection.tsx"]
},
{
"feature": "Settings / Integrations / AI parsing",
"hooks": ["src/components/Settings/useLlmConnection.ts"],
@@ -952,7 +978,6 @@
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
"src/mobile/screens/trip/sheets/MImportSheet.tsx": "Menu that routes to ImpFileStep and ImpListStep, with no logic of its own.",
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
"src/mobile/screens/trip/sheets/MMehrSheet.tsx": "Phone dock overflow menu that only opens tabs and sheets.",
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
-2
View File
@@ -729,8 +729,6 @@
"src/components/Planner/parsedItemToDraft.ts",
"src/components/Planner/placeAmap.test.ts",
"src/components/Planner/placeAmap.ts",
"src/components/Planner/placeCategoryGuess.test.ts",
"src/components/Planner/placeCategoryGuess.ts",
"src/components/Planner/placeCoMaps.test.ts",
"src/components/Planner/placeCoMaps.ts",
"src/components/Planner/placeDialogParts.test.tsx",
+1 -1
View File
@@ -30,7 +30,7 @@
"planner.ts": 53,
"reservations.ts": 21,
"roadtrip.ts": 38,
"settings.ts": 40,
"settings.ts": 37,
"share.ts": 7,
"shared.ts": 2,
"storage.ts": 16,
+2 -2
View File
@@ -5,9 +5,9 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1401,
"src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2293,
"src/components/Map/MapViewGL.tsx": 2291,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
-1
View File
@@ -148,7 +148,6 @@
"tests/integration/api/client.test.ts": 1,
"tests/integration/hooks/useRouteCalculation.test.ts": 1,
"tests/unit/i18n/i18nKeysScript.test.ts": 3,
"tests/unit/mobile/admin/MAdminAddonManager.test.tsx": 2,
"tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx": 1,
"tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx": 1,
"tests/unit/mobile/admin/MAdminStoragePanel.test.tsx": 1,
+1 -1
View File
@@ -156,7 +156,7 @@
"mobile/components/MToastHost.tsx": 2,
"mobile/components/MUserPickerList.tsx": 1,
"mobile/screens/admin/MAdmin.tsx": 9,
"mobile/screens/admin/MAdminAddonManager.tsx": 26,
"mobile/screens/admin/MAdminAddonManager.tsx": 13,
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
+8
View File
@@ -62,6 +62,8 @@ const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
// The desktop assistant window: its own chunk, and nothing at all until a connection is set up.
const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost'))
// The ten phone screens are chunks of their own, alongside the desktop pages
// rather than inside them. Each page used to import its M screen statically and
@@ -403,6 +405,12 @@ export default function App() {
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
{/* The phone has its own assistant screen inside the trip. */}
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
<Suspense fallback={null}><AssistantHost /></Suspense>
</ErrorBoundary>
)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
+133
View File
@@ -0,0 +1,133 @@
/**
* FE-AI-API-001 to FE-AI-API-007
*
* The two streaming calls bypass axios, so they carry by hand what the axios
* instance would add (the cookie, the socket id) and turn a refusal before the
* stream into a typed error the chat can name. The refusal's code comes from
* the body when the server names one, and from the status otherwise.
*/
import type { AssistantChatRequest } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { assistantApi, AssistantRequestError } from './assistant';
import { getSocketId } from './websocket';
const BODY: AssistantChatRequest = { locale: 'en', timeZone: 'Europe/Berlin', threadId: null, text: 'Hi' };
function streamResponse(text: string): Response {
const bytes = new TextEncoder().encode(text);
let sent = false;
return {
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => {
if (sent) return { done: true, value: undefined };
sent = true;
return { done: false, value: bytes };
},
cancel: async () => {},
}),
},
} as unknown as Response;
}
function refusal(status: number, body: unknown): Response {
return {
ok: false,
status,
body: null,
json: async () => {
if (body === undefined) throw new SyntaxError('not json');
return body;
},
} as unknown as Response;
}
async function refusedWith(res: Response) {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(res);
try {
await assistantApi.chat(4, BODY, new AbortController().signal);
} catch (err) {
return err;
}
throw new Error('expected a refusal');
}
afterEach(() => vi.restoreAllMocks());
describe('assistantApi streams', () => {
it('FE-AI-API-001: posts the message as JSON with the cookie and the socket id, and yields the chunks', async () => {
vi.mocked(getSocketId).mockReturnValueOnce('sock-1');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(streamResponse('data: {"type":"start","messageId":"m1"}\n\ndata: [DONE]\n\n'));
const signal = new AbortController().signal;
const chunks: unknown[] = [];
for await (const chunk of await assistantApi.chat(4, BODY, signal)) chunks.push(chunk);
expect(chunks).toEqual([{ type: 'start', messageId: 'm1' }]);
const [url, init] = fetchSpy.mock.calls[0] ?? [];
expect(url).toBe('/api/trips/4/assistant/chat');
expect(init).toMatchObject({ method: 'POST', credentials: 'include', signal, body: JSON.stringify(BODY) });
expect(init?.headers).toMatchObject({
'content-type': 'application/json',
accept: 'text/event-stream',
'X-Socket-Id': 'sock-1',
});
});
it('FE-AI-API-002: sends decisions to the thread they belong to', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamResponse('data: [DONE]\n\n'));
const body = { locale: 'de', timeZone: 'UTC', decisions: [{ toolCallId: 'c1', approved: true }] };
await assistantApi.decide(4, 7, body, new AbortController().signal);
expect(fetchSpy.mock.calls[0]?.[0]).toBe('/api/trips/4/assistant/threads/7/decisions');
});
it('FE-AI-API-003: a refusal names the code the body gives', async () => {
const err = await refusedWith(refusal(429, { error: 'Daily limit reached', code: 'daily_limit' }));
expect(err).toBeInstanceOf(AssistantRequestError);
expect(err).toMatchObject({ code: 'daily_limit', status: 429 });
});
it('FE-AI-API-004: without a known code the status decides', async () => {
expect(await refusedWith(refusal(429, { error: 'slow down' }))).toMatchObject({ code: 'too_many_requests' });
expect(await refusedWith(refusal(409, { error: 'x', code: 'conflict' }))).toMatchObject({ code: 'busy' });
expect(await refusedWith(refusal(502, undefined))).toMatchObject({ code: 'provider_unavailable' });
expect(await refusedWith(refusal(500, { error: 'boom' }))).toMatchObject({ code: 'generic' });
});
it('FE-AI-API-005: an accepted request without a body is a generic failure, not a silent empty answer', async () => {
const err = await refusedWith({ ok: true, status: 200, body: null } as unknown as Response);
expect(err).toMatchObject({ code: 'generic', status: 200 });
});
});
describe('assistantApi JSON calls', () => {
it('FE-AI-API-006: undo posts an empty JSON body to the escaped tool call id', async () => {
let seen: { path: string; body: unknown } | null = null;
server.use(
http.post('/api/trips/4/assistant/tool-calls/:id/undo', async ({ request, params }) => {
seen = { path: String(params.id), body: await request.json() };
return HttpResponse.json({ undone: true });
})
);
await expect(assistantApi.undo(4, 'a/b')).resolves.toEqual({ undone: true });
expect(seen).toEqual({ path: 'a/b', body: {} });
});
it('FE-AI-API-007: stop posts an empty JSON body', async () => {
let body: unknown = null;
server.use(
http.post('/api/trips/4/assistant/stop', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
})
);
await expect(assistantApi.stop(4)).resolves.toEqual({ success: true });
expect(body).toEqual({});
});
});
+189
View File
@@ -0,0 +1,189 @@
import {
ASSISTANT_ERROR_CODES,
type AiAdminConnectionResponse,
type AiAdminSettings,
type AiApiKeyConnectRequest,
type AiAuthStartResponse,
type AiChatgptFinishRequest,
type AiClaudeConnectorResponse,
type AiConnectionScope,
type AiConnectionView,
type AiInstanceConnectionRequest,
type AiModelListResponse,
type AiOpenrouterFinishRequest,
type AiPrivacyAckRequest,
type AiStatusResponse,
type AssistantAddFoundPlaceRequest,
type AssistantAddFoundPlaceResponse,
type AssistantChatRequest,
type AssistantDecisionsRequest,
type AssistantErrorCode,
type AssistantShareRequest,
type AssistantThreadListResponse,
type AssistantThreadResponse,
type AssistantUndoResponse,
type CollabMessageResponse,
type SuccessResponse,
type UiStreamChunk,
} from '@trek/shared';
import apiClient from './client';
import { parseUiMessageStream } from './uiMessageStream';
import { getSocketId } from './websocket';
/**
* The trip assistant and the AI connections behind it, in their own file
* rather than in the `client.ts` god module.
*
* The two streaming calls (a new message, and the decisions on held changes)
* go through `fetch`, not axios: the answer is a server-sent event stream that
* runs for as long as the model works, and axios would buffer it whole behind
* its 8 s timeout. Everything else is plain JSON through `apiClient`.
*/
/** A refusal the server answered before the stream opened, named by its `AssistantErrorCode`. */
export class AssistantRequestError extends Error {
readonly code: AssistantErrorCode;
readonly status: number;
constructor(code: AssistantErrorCode, status: number) {
super(`Assistant request refused: ${code}`);
this.name = 'AssistantRequestError';
this.code = code;
this.status = status;
}
}
const KNOWN_CODES: ReadonlySet<string> = new Set(ASSISTANT_ERROR_CODES);
/**
* The code of a refusal: the body's `code` when it names one, otherwise what
* the status alone can say.
*/
async function refusalCode(res: Response): Promise<AssistantErrorCode> {
try {
const body: unknown = await res.json();
const code = (body as { code?: unknown } | null)?.code;
if (typeof code === 'string' && KNOWN_CODES.has(code)) return code as AssistantErrorCode;
} catch {
// Not JSON (a proxy's error page); the status below still says something.
}
// A bare 429 is TREK's own limit or a proxy's; a provider's limit always names its code.
if (res.status === 429) return 'too_many_requests';
if (res.status === 409) return 'busy';
if (res.status === 502 || res.status === 503 || res.status === 504) return 'provider_unavailable';
return 'generic';
}
async function openStream(path: string, body: unknown, signal: AbortSignal): Promise<AsyncGenerator<UiStreamChunk>> {
// A file goes as multipart, where the browser sets the content type with its boundary.
const form = body instanceof FormData;
const headers: Record<string, string> = form
? { accept: 'text/event-stream' }
: { 'content-type': 'application/json', accept: 'text/event-stream' };
const socketId = getSocketId();
if (socketId) headers['X-Socket-Id'] = socketId;
const res = await fetch(`/api${path}`, {
method: 'POST',
credentials: 'include',
headers,
body: form ? body : JSON.stringify(body),
signal,
});
if (!res.ok) throw new AssistantRequestError(await refusalCode(res), res.status);
if (!res.body) throw new AssistantRequestError('generic', res.status);
return parseUiMessageStream(res.body);
}
const tripBase = (tripId: number) => `/trips/${tripId}/assistant`;
/**
* For the calls that make the server go and ask the provider (listing models,
* testing a key, exchanging a sign-in code): the shared 8 s is TREK's own
* budget and too short for a provider's.
*/
const viaProvider = { timeout: 30_000 };
export const assistantApi = {
threads: (tripId: number): Promise<AssistantThreadListResponse> =>
apiClient.get(`${tripBase(tripId)}/threads`).then((r) => r.data),
thread: (tripId: number, threadId: number): Promise<AssistantThreadResponse> =>
apiClient.get(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
deleteThread: (tripId: number, threadId: number): Promise<SuccessResponse> =>
apiClient.delete(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
undo: (tripId: number, toolCallId: string): Promise<AssistantUndoResponse> =>
apiClient.post(`${tripBase(tripId)}/tool-calls/${encodeURIComponent(toolCallId)}/undo`, {}).then((r) => r.data),
// Ends this user's running turn, wherever it runs (another tab included).
stop: (tripId: number): Promise<SuccessResponse> =>
apiClient.post(`${tripBase(tripId)}/stop`, {}).then((r) => r.data),
// A place a search found, suggested for a day: answers with the message holding the change.
addFoundPlace: (
tripId: number,
threadId: number,
body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
// An answer posted to the trip chat, marked as shared from the assistant; the client's socket id skips the echo.
share: (
tripId: number,
threadId: number,
messageId: string,
body: AssistantShareRequest
): Promise<CollabMessageResponse> =>
apiClient
.post(`${tripBase(tripId)}/threads/${threadId}/messages/${encodeURIComponent(messageId)}/share`, body)
.then((r) => r.data),
chat: (tripId: number, body: AssistantChatRequest, signal: AbortSignal) =>
openStream(`${tripBase(tripId)}/chat`, body, signal),
decide: (tripId: number, threadId: number, body: AssistantDecisionsRequest, signal: AbortSignal) =>
openStream(`${tripBase(tripId)}/threads/${threadId}/decisions`, body, signal),
// A booking file: the server reads it and proposes each booking it finds as a change.
attach: (tripId: number, form: FormData, signal: AbortSignal) =>
openStream(`${tripBase(tripId)}/attachments`, form, signal),
};
/** A user's own connection, and what the assistant runs on for them. */
export const aiApi = {
status: (): Promise<AiStatusResponse> => apiClient.get('/ai/status').then((r) => r.data),
models: (): Promise<AiModelListResponse> => apiClient.get('/ai/models', viaProvider).then((r) => r.data),
// Saving tests the key against the provider first.
connectKey: (body: AiApiKeyConnectRequest): Promise<AiConnectionView> =>
apiClient.put('/ai/connection', body, viaProvider).then((r) => r.data),
setModel: (model: string): Promise<AiConnectionView> =>
apiClient.patch('/ai/connection/model', { model }).then((r) => r.data),
disconnect: (): Promise<SuccessResponse> => apiClient.delete('/ai/connection').then((r) => r.data),
ackPrivacy: (body: AiPrivacyAckRequest): Promise<SuccessResponse> =>
apiClient.post('/ai/privacy-ack', body).then((r) => r.data),
chatgptStart: (scope: AiConnectionScope = 'user'): Promise<AiAuthStartResponse> =>
apiClient
.post(scope === 'instance' ? '/admin/ai/connect/chatgpt/start' : '/ai/connect/chatgpt/start', {})
.then((r) => r.data),
chatgptFinish: (body: AiChatgptFinishRequest): Promise<AiConnectionView> =>
apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data),
chatgptSignOut: (): Promise<SuccessResponse> =>
apiClient.post('/ai/connect/chatgpt/sign-out', {}).then((r) => r.data),
openrouterStart: (scope: AiConnectionScope): Promise<AiAuthStartResponse> =>
apiClient
.post(scope === 'instance' ? '/admin/ai/connect/openrouter/start' : '/ai/connect/openrouter/start', { scope })
.then((r) => r.data),
openrouterFinish: (body: AiOpenrouterFinishRequest): Promise<AiConnectionView> =>
apiClient.post('/ai/connect/openrouter/finish', body, viaProvider).then((r) => r.data),
};
/** The connection the instance provides for everyone, and the assistant's admin settings. */
export const aiAdminApi = {
settings: (): Promise<AiAdminSettings> => apiClient.get('/admin/ai/settings').then((r) => r.data),
saveSettings: (body: AiAdminSettings): Promise<AiAdminSettings> =>
apiClient.put('/admin/ai/settings', body).then((r) => r.data),
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
};
export const claudeConnectorApi = {
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
};
+150
View File
@@ -0,0 +1,150 @@
/**
* FE-AI-STREAM-001 to FE-AI-STREAM-011
*
* The parser reads the server's assistant stream, so its contract is the
* shared fixture: the same file the server's writer is tested against. What
* the network does to that stream (cutting it at any byte, CRLF from a proxy,
* keep-alive comments) must not change what comes out.
*/
import { uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { MAX_EVENT_CHARS, parseUiMessageStream, STALL_MS } from './uiMessageStream';
const FIXTURE = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), '../../../shared/src/assistant/__fixtures__/ui-stream.v1.txt'),
'utf8'
);
/** A response body that hands out the given pieces one read at a time. */
function bodyOf(pieces: (string | Uint8Array)[]) {
const encoder = new TextEncoder();
let i = 0;
const cancel = vi.fn(async () => {});
const body = {
getReader: () => ({
read: async () => {
if (i >= pieces.length) return { done: true, value: undefined };
const piece = pieces[i++];
return { done: false, value: typeof piece === 'string' ? encoder.encode(piece) : piece };
},
cancel,
}),
} as unknown as ReadableStream<Uint8Array>;
return { body, cancel };
}
async function collect(body: ReadableStream<Uint8Array>): Promise<UiStreamChunk[]> {
const chunks: UiStreamChunk[] = [];
for await (const chunk of parseUiMessageStream(body)) chunks.push(chunk);
return chunks;
}
/** The fixture's chunks as plain JSON, the way a reader of the file sees them. */
function fixtureChunks(): UiStreamChunk[] {
return FIXTURE.split('\n\n')
.map((event) => event.replace(/^data: /, '').trim())
.filter((data) => data && data !== '[DONE]')
.map((data) => uiStreamChunkSchema.parse(JSON.parse(data)));
}
describe('parseUiMessageStream', () => {
it('FE-AI-STREAM-001: reads the shared fixture into its chunks, in order', async () => {
const chunks = await collect(bodyOf([FIXTURE]).body);
expect(chunks).toEqual(fixtureChunks());
expect(chunks.map((c) => c.type)).toContain('tool-approval-request');
expect(chunks[chunks.length - 1]).toEqual({ type: 'finish', finishReason: 'tool-calls' });
});
it('FE-AI-STREAM-002: gives the same chunks when every byte arrives in its own read', async () => {
const bytes = new TextEncoder().encode(FIXTURE);
const pieces = Array.from(bytes, (b) => new Uint8Array([b]));
expect(await collect(bodyOf(pieces).body)).toEqual(fixtureChunks());
});
it('FE-AI-STREAM-003: reads CRLF line ends, also when a CR and its LF arrive apart', async () => {
const crlf = FIXTURE.replace(/\n/g, '\r\n');
const cut = crlf.indexOf('\r\n') + 1;
expect(await collect(bodyOf([crlf.slice(0, cut), crlf.slice(cut)]).body)).toEqual(fixtureChunks());
});
it('FE-AI-STREAM-004: skips keep-alive comments and fields other than data', async () => {
const stream = [
': keepalive\n\n',
'event: message\nid: 1\ndata: {"type":"start-step"}\n\n',
': keepalive\n\n',
'data: [DONE]\n\n',
].join('');
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'start-step' }]);
});
it('FE-AI-STREAM-005: skips chunk types this client does not know, and torn JSON', async () => {
const stream = [
'data: {"type":"reasoning-delta","delta":"hm"}\n\n',
'data: {"type":"text-delta","id":"t"\n\n',
'data: {"type":"text-delta","id":"t","delta":"ok"}\n\n',
'data: [DONE]\n\n',
].join('');
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'ok' }]);
});
it('FE-AI-STREAM-006: reads an unknown error code as generic', async () => {
const stream = 'data: {"type":"error","errorText":"quota_exploded"}\n\ndata: [DONE]\n\n';
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'error', errorText: 'generic' }]);
});
it('FE-AI-STREAM-007: joins the lines of a multi-line data field', async () => {
const stream = 'data: {"type":"text-delta",\ndata: "id":"t","delta":"x"}\n\ndata: [DONE]\n\n';
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'x' }]);
});
it('FE-AI-STREAM-008: stops at [DONE] and lets go of the connection', async () => {
const { body, cancel } = bodyOf([
'data: {"type":"start-step"}\n\ndata: [DONE]\n\ndata: {"type":"finish-step"}\n\n',
]);
expect(await collect(body)).toEqual([{ type: 'start-step' }]);
expect(cancel).not.toHaveBeenCalled();
});
it('FE-AI-STREAM-009: a stream that closes before [DONE] broke off, after the chunks it finished', async () => {
const { body, cancel } = bodyOf(['data: {"type":"start-step"}\n\ndata: {"type":"finish-st']);
const seen: UiStreamChunk[] = [];
const read = async () => {
for await (const chunk of parseUiMessageStream(body)) seen.push(chunk);
};
await expect(read()).rejects.toThrow('broke off');
expect(seen).toEqual([{ type: 'start-step' }]);
expect(cancel).not.toHaveBeenCalled();
});
it('FE-AI-STREAM-011: a stream that stays silent past the stall limit fails and lets go', async () => {
vi.useFakeTimers();
try {
const cancel = vi.fn(async () => {});
const body = {
getReader: () => ({ read: () => new Promise(() => {}), cancel }),
} as unknown as ReadableStream<Uint8Array>;
const failed = expect(collect(body)).rejects.toThrow('stalled');
await vi.advanceTimersByTimeAsync(STALL_MS);
await failed;
expect(cancel).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('FE-AI-STREAM-010: refuses an event past the size limit, and a consumer leaving early cancels the read', async () => {
const huge = bodyOf([`data: ${'x'.repeat(MAX_EVENT_CHARS + 1)}`]);
await expect(collect(huge.body)).rejects.toThrow('too large');
expect(huge.cancel).toHaveBeenCalledTimes(1);
const early = bodyOf([FIXTURE]);
for await (const chunk of parseUiMessageStream(early.body)) {
expect(chunk.type).toBe('start');
break;
}
expect(early.cancel).toHaveBeenCalledTimes(1);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { UI_STREAM_DONE, uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared';
/**
* Reads the assistant's server-sent events into typed chunks.
*
* The wire format is the `@trek/shared` stream contract: one `data: <json>`
* event per chunk, closed by `data: [DONE]`. Our own parser rather than the
* `assistant-stream` one, because that one drops `tool-approval-request`, the
* chunk a held change hangs on, and it would pull the chat runtime into every
* chunk that only needs the data layer.
*
* What it tolerates, because proxies and the network do these things: events
* split across reads at any byte, CRLF line ends, `:` keep-alive comments,
* and fields other than `data`. A chunk that does not match the contract,
* a type this client does not know among them, is skipped, so the server can
* add chunk types without breaking an older tab. An event longer than
* MAX_EVENT_CHARS ends the stream with an error instead of growing the buffer
* without bound.
*
* The server always closes with `[DONE]` and sends a keep-alive every 15
* seconds, so a stream that ends without the marker, or stays silent for
* STALL_MS, lost its server (a restart, a dropped proxy) and fails instead of
* leaving the chat waiting for an answer that will not come.
*/
export const MAX_EVENT_CHARS = 1024 * 1024;
export const STALL_MS = 45_000;
/** The next read, or a failure once nothing came for STALL_MS. */
async function readOrStall(reader: ReadableStreamDefaultReader<Uint8Array>) {
let timer: ReturnType<typeof setTimeout> | undefined;
const stalled = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('Assistant stream stalled')), STALL_MS);
});
try {
return await Promise.race([reader.read(), stalled]);
} finally {
clearTimeout(timer);
}
}
export async function* parseUiMessageStream(body: ReadableStream<Uint8Array>): AsyncGenerator<UiStreamChunk> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finished = false;
try {
for (;;) {
const { done, value } = await readOrStall(reader);
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
// A CR at the very end may be the first half of a CRLF; it waits for the next read.
const carry = !done && buffer.endsWith('\r') ? '\r' : '';
if (carry) buffer = buffer.slice(0, -1);
buffer = buffer.replace(/\r\n?/g, '\n');
let boundary = buffer.indexOf('\n\n');
while (boundary >= 0) {
const data = dataOf(buffer.slice(0, boundary));
buffer = buffer.slice(boundary + 2);
if (data === UI_STREAM_DONE) {
finished = true;
return;
}
const chunk = data === null ? null : chunkOf(data);
if (chunk) yield chunk;
boundary = buffer.indexOf('\n\n');
}
buffer += carry;
if (buffer.length > MAX_EVENT_CHARS) throw new Error('Assistant stream event too large');
// The connection closed before [DONE]: the server went away mid-answer.
if (done) {
finished = true;
throw new Error('Assistant stream broke off');
}
}
} finally {
// Leaving early (the consumer stopped, or an error) releases the connection.
if (!finished) {
try {
await reader.cancel();
} catch {
// The stream already failed; the error that brought us here is the one that counts.
}
}
}
}
/** The joined `data` lines of one event, or null for an event without any (a comment). */
function dataOf(event: string): string | null {
const lines: string[] = [];
for (const line of event.split('\n')) {
if (line === 'data' || line.startsWith('data:')) {
const value = line.slice(5);
lines.push(value.startsWith(' ') ? value.slice(1) : value);
}
}
return lines.length ? lines.join('\n') : null;
}
function chunkOf(data: string): UiStreamChunk | null {
let json: unknown;
try {
json = JSON.parse(data);
} catch {
// A torn or foreign event; the next one is still worth reading.
return null;
}
const parsed = uiStreamChunkSchema.safeParse(json);
return parsed.success ? parsed.data : null;
}
+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 -174
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,11 +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 AiAddonTile from './addons/AiAddonTile'
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
import WandererMark from '../Tours/wanderer/WandererMark'
@@ -74,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,
@@ -154,33 +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} />
)),
]
}
return null
}
@@ -209,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">
@@ -242,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>
)
})}
@@ -282,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>
);
}
@@ -0,0 +1,339 @@
// 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(),
adminSettings: vi.fn(),
saveAdminSettings: vi.fn(),
adminConnection: vi.fn(),
saveAdminConnection: vi.fn(),
removeAdminConnection: vi.fn(),
adminModels: vi.fn(),
}));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
const LIVE: AiAdminConnectionResponse = {
connection: {
scope: 'instance',
kind: 'openai',
model: 'gpt-5.5',
hint: '…9f3c',
status: 'ok',
host: 'api.openai.com',
baseUrl: null,
},
prefill: null,
};
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(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
repo.status.mockRejectedValue(new Error('not needed'));
repo.adminSettings.mockResolvedValue(SETTINGS);
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
repo.adminConnection.mockResolvedValue({ connection: null, prefill: null });
repo.removeAdminConnection.mockResolvedValue(undefined);
repo.adminModels.mockResolvedValue([{ id: 'gpt-5.5', label: 'GPT-5.5', supportsTools: true }]);
});
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();
// The admin may share their own ChatGPT plan with everyone, so its tile is offered too.
expect(screen.getByRole('button', { name: /Sign in with ChatGPT/ })).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();
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.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);
renderDialog();
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('…9f3c')).toBeInTheDocument();
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Change' }));
expect(screen.getByText('What would you like to chat with?')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.getByText('OpenAI')).toBeInTheDocument();
});
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 });
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' });
await user.click(buttons[buttons.length - 1]);
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();
// The link in the hint, not the tab of the same name above it.
const link = screen.getAllByRole('button', { name: 'Connection' }).find((b) => !b.hasAttribute('aria-pressed'));
await user.click(link as HTMLElement);
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' },
});
});
});
@@ -0,0 +1,270 @@
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 {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
import { NumericInput } from '../../shared/NumericInput';
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>;
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint';
/** Who pays, as three rows to pick one from: the labels are too long for a segmented track in a shelf. */
function PolicyChoice({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<span className={EYEBROW} style={fs(9.5)}>
{t('assistant.admin.policy')}
</span>
<div role="radiogroup" aria-label={t('assistant.admin.policy')} className="flex flex-col gap-1.5">
{POLICY_CHOICES.map((choice) => {
const on = A.policy === choice.value;
return (
<button
key={choice.value}
type="button"
role="radio"
aria-checked={on}
onClick={() => A.setPolicy(choice.value)}
className={`flex items-center gap-2 rounded-[10px] border px-3 py-2 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
>
<span
className={`min-w-0 flex-1 font-medium ${on ? 'text-content' : 'text-content-muted'}`}
style={fs(12.5, 'body')}
>
{t(choice.labelKey)}
</span>
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-content" />}
</button>
);
})}
</div>
{A.policyHintKey && <SettingsHint>{t(A.policyHintKey)}</SettingsHint>}
</div>
);
}
/** The connection everyone runs on, once set up: who answers, its model, and replacing or removing it. */
function LiveConnection({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const { live, provider, models, busy, setModel, replace, askRemove } = A.instance;
if (!live) return null;
const status = CONNECTION_STATUS[live.status];
return (
<div
className={`flex flex-col gap-3 rounded-[12px] border border-edge-faint bg-surface-card p-3 ${busy ? 'opacity-60' : ''}`}
>
<div className="flex items-center gap-2.5">
<ProviderMark kind={live.kind} />
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
{provider}
</p>
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
{live.hint ?? live.host}
</p>
</div>
<StatusPill tone={status.tone}>{t(status.labelKey)}</StatusPill>
</div>
{live.status === 'ok' && (
<EditorField label={t('assistant.connect.model')} hint={t('assistant.connect.modelHint')}>
<CustomSelect
ariaLabel={t('assistant.connect.model')}
value={live.model}
onChange={(v) => setModel(String(v))}
options={models}
size="sm"
disabled={busy}
searchable={models.length > 8}
/>
</EditorField>
)}
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
<button type="button" onClick={replace} disabled={busy} className={SETTINGS_BUTTON}>
{t('common.change')}
</button>
<button type="button" onClick={askRemove} disabled={busy} className={SETTINGS_BUTTON_DANGER}>
{t('assistant.admin.connectionRemove')}
</button>
</div>
</div>
);
}
function InstanceConnection({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const { live, connection, replacing, cancelReplace, onConnected } = A.instance;
return (
<div className="flex flex-col gap-2.5">
<span className={EYEBROW} style={fs(9.5)}>
{t('assistant.admin.connection')}
</span>
{!replacing && <LiveConnection A={A} />}
{!live && <SettingsHint>{t('assistant.admin.connectionNone')}</SettingsHint>}
{A.showPanel && (
<AiConnectPanel scope="instance" onConnected={onConnected} prefill={connection?.prefill ?? null} />
)}
{replacing && (
<button
type="button"
onClick={cancelReplace}
className={`${SETTINGS_BUTTON} self-start`}
style={fs(12.5, 'body')}
>
{t('common.cancel')}
</button>
)}
</div>
);
}
/** 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 fieldId = useId();
return (
<div className="flex flex-col gap-4">
{A.instancePays && (
<EditorField
label={t('assistant.admin.dailyLimit')}
htmlFor={`${fieldId}-daily`}
hint={t('assistant.admin.dailyLimitHint')}
>
<NumericInput
id={`${fieldId}-daily`}
mode="integer"
className={`${INPUT} tabular-nums`}
value={A.dailyDraft}
onValueChange={A.setDailyDraft}
onBlur={A.commitDailyLimit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
</EditorField>
)}
<EditorField label={t('assistant.admin.retention')}>
<CustomSelect
ariaLabel={t('assistant.admin.retention')}
value={A.retention}
onChange={(v) => A.setRetention(String(v))}
options={A.retentionOptions}
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}
onClose={A.instance.cancelRemove}
onConfirm={A.instance.remove}
title={t('assistant.admin.connectionRemove')}
message={t('assistant.connect.disconnectConfirm', { provider: A.instance.provider })}
confirmLabel={t('assistant.admin.connectionRemove')}
danger
/>
</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.
*/
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),
};
}
@@ -0,0 +1,167 @@
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
import { MASKED_SETTING_VALUE } from '@trek/shared';
import { createElement, type ReactNode } from 'react';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { useAssistantStore } from '../../../store/assistantStore';
const repo = vi.hoisted(() => ({
status: vi.fn(),
adminSettings: vi.fn(),
saveAdminSettings: vi.fn(),
adminConnection: vi.fn(),
saveAdminConnection: vi.fn(),
removeAdminConnection: vi.fn(),
adminModels: vi.fn(),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const { useAiAdminConfig } = await import('./useAiAdminConfig');
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(
MemoryRouter,
{ initialEntries: ['/admin?tab=addons'] },
createElement(TranslationProvider, null, children)
);
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
const LIVE: AiAdminConnectionResponse = {
connection: {
scope: 'instance',
kind: 'anthropic',
model: 'claude-sonnet',
hint: '…a1b2',
status: 'ok',
host: 'api.anthropic.com',
baseUrl: null,
},
prefill: null,
};
async function renderLoaded() {
const view = renderHook(() => useAiAdminConfig(), { wrapper });
await waitFor(() => expect(view.result.current.loaded).toBe(true));
return view;
}
beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
repo.status.mockRejectedValue(new Error('not needed'));
repo.adminSettings.mockResolvedValue(SETTINGS);
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
repo.adminConnection.mockResolvedValue(NONE);
repo.saveAdminConnection.mockResolvedValue(LIVE);
repo.removeAdminConnection.mockResolvedValue(undefined);
repo.adminModels.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]);
});
describe('useAiAdminConfig', () => {
it('FE-HOOK-AI-ADMIN-001: loads the settings and, without a connection, opens the setup for everyone', async () => {
const { result } = await renderLoaded();
expect(result.current.policy).toBe('instance_or_user');
expect(result.current.policyHintKey).toBe('assistant.admin.policy.instanceOrUserHint');
expect(result.current.instancePays).toBe(true);
expect(result.current.showPanel).toBe(true);
expect(result.current.dailyDraft).toBe('50');
expect(result.current.retention).toBe('90');
expect(result.current.retentionOptions.map((o) => o.label)).toEqual(['30 days', '90 days', '365 days', 'Forever']);
expect(repo.status).toHaveBeenCalled();
});
it('FE-HOOK-AI-ADMIN-002: a policy change saves at once, and a refused one is taken back', async () => {
const { result } = await renderLoaded();
await act(async () => result.current.setPolicy('user'));
await waitFor(() => expect(result.current.policy).toBe('user'));
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
expect(result.current.instancePays).toBe(false);
repo.saveAdminSettings.mockRejectedValueOnce(new Error('nope'));
await act(async () => result.current.setPolicy('instance'));
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
expect(result.current.policy).toBe('user');
});
it('FE-HOOK-AI-ADMIN-003: the daily limit is kept within 1 to 10000 and saved when the field is left', async () => {
const { result } = await renderLoaded();
act(() => result.current.setDailyDraft('20000'));
await act(async () => result.current.commitDailyLimit());
expect(result.current.dailyDraft).toBe('10000');
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, dailyTurns: 10000 });
vi.clearAllMocks();
act(() => result.current.setDailyDraft(''));
await act(async () => result.current.commitDailyLimit());
expect(result.current.dailyDraft).toBe('10000');
expect(repo.saveAdminSettings).not.toHaveBeenCalled();
});
it('FE-HOOK-AI-ADMIN-004: a retention period set elsewhere stays selectable', async () => {
repo.adminSettings.mockResolvedValue({ ...SETTINGS, retention: { mode: 'days', days: 120 } });
const { result } = await renderLoaded();
expect(result.current.retentionOptions.map((o) => o.value)).toEqual(['30', '90', '120', '365', 'forever']);
await act(async () => result.current.setRetention('forever'));
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, retention: { mode: 'forever' } });
});
it('FE-HOOK-AI-ADMIN-005: another model keeps the stored key by sending the mask', async () => {
repo.adminConnection.mockResolvedValue(LIVE);
const { result } = await renderLoaded();
expect(result.current.showPanel).toBe(false);
await waitFor(() => expect(result.current.instance.models).toHaveLength(1));
await act(async () => result.current.instance.setModel('claude-haiku'));
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
kind: 'anthropic',
apiKey: MASKED_SETTING_VALUE,
model: 'claude-haiku',
});
});
it('FE-HOOK-AI-ADMIN-006: an own server without a key sends none', async () => {
repo.adminConnection.mockResolvedValue({
connection: {
scope: 'instance',
kind: 'custom',
model: 'llama3',
hint: null,
status: 'ok',
host: 'ollama:11434',
baseUrl: 'http://ollama:11434/v1',
},
prefill: null,
});
const { result } = await renderLoaded();
await act(async () => result.current.instance.setModel('qwen3:8b'));
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
kind: 'custom',
baseUrl: 'http://ollama:11434/v1',
model: 'qwen3:8b',
});
});
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
const { result } = await renderLoaded();
act(() => result.current.instance.replace());
expect(result.current.showPanel).toBe(true);
act(() => result.current.instance.cancelReplace());
expect(result.current.showPanel).toBe(false);
act(() => result.current.instance.askRemove());
expect(result.current.instance.confirmingRemove).toBe(true);
act(() => result.current.instance.remove());
await waitFor(() => expect(result.current.instance.live).toBeNull());
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
expect(result.current.instance.confirmingRemove).toBe(false);
expect(result.current.showPanel).toBe(true);
});
});
@@ -0,0 +1,221 @@
import {
MASKED_SETTING_VALUE,
type AiAdminConnectionResponse,
type AiAdminSettings,
type AiInstanceConnectionRequest,
type AiPolicy,
type AiRetention,
} from '@trek/shared';
import { useEffect, useEffectEvent, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
import { useAssistantStore } from '../../../store/assistantStore';
import { providerLabel } from '../../Assistant/providerCatalog';
import { connectErrorKey } from '../../Settings/ai/aiConnectModel';
import { useOpenrouterReturn } from '../../Settings/ai/useAiConnect';
import { modelOptions, useAiModels } from '../../Settings/ai/useAiConnections';
import { useToast } from '../../shared/Toast';
/** Who pays, as the admin reads it ("I pay" rather than "the instance"); the first is the default. */
export const POLICY_CHOICES: readonly { value: AiPolicy; labelKey: string; hintKey: string }[] = [
{
value: 'instance_or_user',
labelKey: 'assistant.admin.policy.instanceOrUser',
hintKey: 'assistant.admin.policy.instanceOrUserHint',
},
{ value: 'instance', labelKey: 'assistant.admin.policy.instance', hintKey: 'assistant.admin.policy.instanceHint' },
{ value: 'user', labelKey: 'assistant.admin.policy.user', hintKey: 'assistant.admin.policy.userHint' },
];
const RETENTION_DAYS = [30, 90, 365];
const FOREVER = 'forever';
const MAX_DAILY_TURNS = 10_000;
/** A retention as the select's value: its days, or `forever`. */
function retentionValue(retention: AiRetention): string {
return retention.mode === 'forever' ? FOREVER : String(retention.days);
}
function retentionOf(value: string): AiRetention {
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
}
/** Policy, daily limit and retention: each change is saved at once and taken back when the server refuses it. */
function useAdminSettings(onSaved: () => void) {
const { t } = useTranslation();
const toast = useToast();
const [settings, setSettings] = useState<AiAdminSettings | null>(null);
const [dailyDraft, setDailyDraft] = useState('');
const adopt = (next: AiAdminSettings) => {
setSettings(next);
setDailyDraft(String(next.dailyTurns));
};
const save = async (patch: Partial<AiAdminSettings>) => {
if (!settings) return;
const before = settings;
setSettings({ ...settings, ...patch });
try {
adopt(await aiConnectionsRepo.saveAdminSettings({ ...settings, ...patch }));
onSaved();
} catch {
adopt(before);
toast.error(t('assistant.error.generic'));
}
};
/** The typed limit, kept within what the server takes, saved when the field is left. */
const commitDailyLimit = () => {
if (!settings) return;
const typed = Number.parseInt(dailyDraft, 10);
const next = Number.isFinite(typed) ? Math.min(MAX_DAILY_TURNS, Math.max(1, typed)) : settings.dailyTurns;
setDailyDraft(String(next));
if (next !== settings.dailyTurns) void save({ dailyTurns: next });
};
const current = settings ? retentionValue(settings.retention) : '';
// A period set some other way stays selectable instead of reading as blank.
const unlisted = current !== '' && current !== FOREVER && !RETENTION_DAYS.includes(Number(current));
const days = unlisted ? [...RETENTION_DAYS, Number(current)].sort((a, b) => a - b) : RETENTION_DAYS;
return {
settings,
adopt,
setPolicy: (policy: AiPolicy) => void save({ policy }),
dailyDraft,
setDailyDraft,
commitDailyLimit,
retention: current,
retentionOptions: [
...days.map((d) => ({ value: String(d), label: t('assistant.admin.retentionDays', { count: d }) })),
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
],
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
};
}
/** The connection for everyone: shown, its model switched, replaced or removed. */
function useInstanceConnection(onChanged: () => void) {
const { t } = useTranslation();
const toast = useToast();
const [connection, setConnection] = useState<AiAdminConnectionResponse | null>(null);
const [replacing, setReplacing] = useState(false);
const [confirmingRemove, setConfirmingRemove] = useState(false);
const [busy, setBusy] = useState(false);
const live = connection?.connection ?? null;
const provider = live ? providerLabel(live.kind, live.host) : '';
const models = useAiModels(
live?.status === 'ok' ? () => aiConnectionsRepo.adminModels() : null,
live ? `${live.kind}@${live.host}` : ''
);
const act = async (action: () => Promise<AiAdminConnectionResponse>, doneKey: string | null) => {
setBusy(true);
try {
setConnection(await action());
onChanged();
if (doneKey) toast.success(t(doneKey));
} catch (err) {
toast.error(t(connectErrorKey(err), { provider }));
} finally {
setBusy(false);
}
};
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
const setModel = (model: string) => {
if (!live || live.kind === 'chatgpt') return;
const request: AiInstanceConnectionRequest =
live.kind === 'custom'
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
: { kind: live.kind, apiKey: MASKED_SETTING_VALUE, model };
void act(() => aiConnectionsRepo.saveAdminConnection(request), 'common.saved');
};
const remove = () => {
setConfirmingRemove(false);
void act(async () => {
await aiConnectionsRepo.removeAdminConnection();
return aiConnectionsRepo.adminConnection();
}, null);
};
const onConnected = () => {
setReplacing(false);
void act(() => aiConnectionsRepo.adminConnection(), null);
};
return {
connection,
setConnection,
live,
provider,
models: live
? modelOptions(models, live.model, {
free: t('assistant.model.free'),
recommended: t('assistant.model.recommended'),
})
: [],
busy,
setModel,
replacing,
replace: () => setReplacing(true),
cancelReplace: () => setReplacing(false),
onConnected,
confirmingRemove,
askRemove: () => setConfirmingRemove(true),
cancelRemove: () => setConfirmingRemove(false),
remove,
};
}
/**
* The AI assistant's admin settings behind both admin shells, on the addon's
* own tile: who pays, the connection everyone runs on, the daily limit while
* the instance pays, and how long chats are kept. The assistant's status is
* reloaded after every change, so the launcher appears or goes at once.
*/
export function useAiAdminConfig() {
const { t } = useTranslation();
const toast = useToast();
const loadStatus = useAssistantStore((s) => s.loadStatus);
const refreshStatus = () => void loadStatus();
const form = useAdminSettings(refreshStatus);
const instance = useInstanceConnection(refreshStatus);
useOpenrouterReturn(instance.onConnected);
const loadOnMount = useEffectEvent((isCancelled: () => boolean) => {
Promise.all([aiConnectionsRepo.adminSettings(), aiConnectionsRepo.adminConnection()]).then(
([settings, connection]) => {
if (isCancelled()) return;
form.adopt(settings);
instance.setConnection(connection);
},
() => {
if (!isCancelled()) toast.error(t('assistant.error.generic'));
}
);
// The connect panel reads whether OpenRouter can return here from the status.
refreshStatus();
});
useEffect(() => {
let cancelled = false;
loadOnMount(() => cancelled);
return () => {
cancelled = true;
};
}, []);
const policy = form.settings?.policy ?? null;
return {
...form,
instance,
loaded: form.settings !== null && instance.connection !== null,
policy,
policyHintKey: POLICY_CHOICES.find((c) => c.value === policy)?.hintKey ?? null,
/** Policy `user` charges nothing to the instance: no connection and no limit to set. */
instancePays: policy !== null && policy !== 'user',
showPanel: instance.live === null || instance.replacing,
};
}
@@ -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,17 @@ 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({
const calls = vi.mocked(adminApi.updateAddon).mock.calls;
expect(calls[calls.length - 1]?.[1]).toEqual({
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
});
});
@@ -212,7 +215,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,33 +2,118 @@ 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';
/**
* 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.
*/
export function useLlmParsingConfig(addon: Addon) {
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 [saving, setSaving] = useState(false);
/** 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;
}
// Local-provider model management.
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),
};
}
/**
* The models a local (Ollama) server has, and pulling the recommended ones.
* The list loads while the form shows the local provider: a provider change or
* opening the form loads it, the address field's own blur reloads it.
*/
function useLocalModels(baseUrl: string, isLocal: boolean, formOpen: boolean, onPulled: (id: string) => void) {
const { t } = useTranslation();
const toast = useToast();
const [installed, setInstalled] = useState<string[]>([]);
const [modelsErr, setModelsErr] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const [pulling, setPulling] = useState<string | null>(null);
const [pullPct, setPullPct] = useState(0);
const [pullStatus, setPullStatus] = useState('');
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
const loadModels = async () => {
if (!isLocal) return;
setLoadingModels(true);
setModelsErr('');
try {
const res = await adminApi.llmLocalModels(effectiveUrl);
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, t('admin.addons.ai.parsing.unreachable')));
setInstalled([]);
} finally {
setLoadingModels(false);
}
};
const loadModelsForProvider = useEffectEvent(() => {
if (formOpen && isLocal) void loadModels();
});
useEffect(() => {
loadModelsForProvider();
}, [isLocal, formOpen]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
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(t('admin.addons.ai.parsing.pulled'));
onPulled(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
} finally {
setPulling(null);
setPullPct(0);
setPullStatus('');
}
};
return { installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull };
}
/**
* 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 [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);
/** 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
@@ -37,83 +122,58 @@ 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;
const local = useLocalModels(baseUrl, provider === 'local', formOpen, setModel);
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
const loadModels = async () => {
if (provider !== 'local') return;
setLoadingModels(true);
setModelsErr('');
try {
const res = await adminApi.llmLocalModels(effectiveUrl);
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'));
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).
const loadModelsForProvider = useEffectEvent(() => {
if (provider === 'local') void loadModels();
});
useEffect(() => {
loadModelsForProvider();
}, [provider]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
setPullStatus('starting…');
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');
setModel(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed');
} finally {
setPulling(null);
setPullPct(0);
setPullStatus('');
}
};
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,18 +184,17 @@ export function useLlmParsingConfig(addon: Addon) {
baseUrlPlaceholder,
apiKey,
setApiKey,
vision,
vision: saved.vision,
setVision,
saving,
installed,
modelsErr,
loadingModels,
pulling,
pullPct,
pullStatus,
isInstalled,
loadModels,
pull,
ownConnection,
savedProvider: saved.provider,
savedModel: saved.model,
formOpen,
startOwn: () => setEditing(true),
cancelOwn: () => setEditing(false),
switchToShared,
...local,
save,
};
}
@@ -0,0 +1,56 @@
/**
* FE-AI-ASK-001 to FE-AI-ASK-003
*
* "Ask" in the place inspector: there only while the assistant can run, only
* in the planner of the place's trip, and it opens the chat about the place.
*/
import type { AiStatusResponse } from '@trek/shared';
import { Route, Routes } from 'react-router';
import { afterEach, describe, expect, it } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import AssistantAskButton from './AssistantAskButton';
const INITIAL = useAssistantStore.getState();
const place = buildPlace({ id: 42, trip_id: 5, name: 'Kinkaku-ji' });
function renderAt(path: string) {
return render(
<Routes>
<Route path="/trips/:id" element={<AssistantAskButton place={place} />} />
<Route path="*" element={<AssistantAskButton place={place} />} />
</Routes>,
{ initialEntries: [path] }
);
}
afterEach(() => useAssistantStore.setState(INITIAL, true));
describe('AssistantAskButton', () => {
it('FE-AI-ASK-001: shows nothing until the assistant has a connection to use', () => {
renderAt('/trips/5');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('FE-AI-ASK-002: opens the assistant with the place as its context', () => {
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
renderAt('/trips/5');
fireEvent.click(screen.getByRole('button', { name: 'Ask: Kinkaku-ji' }));
const state = useAssistantStore.getState();
expect(state.window).toEqual({ open: true, minimized: false });
expect(chatOf(state, 5).focus).toEqual({ placeIds: [42] });
});
it('FE-AI-ASK-003: is not offered outside the planner of the trip the place belongs to', () => {
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
const { unmount } = renderAt('/trips/6');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
unmount();
renderAt('/collections/3');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,34 @@
import { Sparkles } from 'lucide-react';
import { useMatch } from 'react-router';
import { useTranslation } from '../../i18n';
import type { Place } from '../../types';
import Tooltip from '../shared/Tooltip';
import { useAssistantAsk } from './useAssistantAsk';
/**
* The place inspector's "Ask": a round button in its head, beside close, that
* opens the assistant about this place. Only in the planner of the place's own
* trip; a saved place in a collection has no trip to ask about.
*/
export default function AssistantAskButton({ place }: { place: Place }) {
const { t } = useTranslation();
const inTrip = useMatch('/trips/:id');
const tripId = inTrip && Number(inTrip.params.id) === place.trip_id ? place.trip_id : null;
const { available, askAbout } = useAssistantAsk(tripId);
if (!available) return null;
const label = t('assistant.context.ask');
return (
<Tooltip label={label}>
<button
type="button"
onClick={() => askAbout(place.id)}
aria-label={`${label}: ${place.name}`}
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content"
>
<Sparkles size={15} strokeWidth={2.2} aria-hidden="true" />
</button>
</Tooltip>
);
}
@@ -0,0 +1,350 @@
/**
* FE-AI-CARD-001 to FE-AI-CARD-013
*
* The change card: what it asks before anything is written, which decisions
* one press sends, and what the receipt offers afterwards (the map, undo
* while its window is open, and what a failed undo says). Then what ties it
* to the map: a row lights its pin and the pin its row, the days it touches
* read as a band, the phone hops to the map, and once applied the block can
* be compared before and after and taken back as a whole.
*/
import type { AssistantApplied, AssistantChangeEffect, AssistantChangePreview } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import AssistantChangeCard from './AssistantChangeCard';
import AssistantCompareSwitch from './AssistantCompareSwitch';
import type { ChangeItem } from './assistantParts';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
const { addToast } = vi.hoisted(() => ({ addToast: vi.fn() }));
// The toasts a component raises, read as (message, type) the way the toast host takes them.
vi.mock('../shared/Toast', async (importOriginal) => ({
...(await importOriginal<typeof import('../shared/Toast')>()),
useToast: () => ({
success: (message: string) => addToast(message, 'success', undefined),
error: (message: string) => addToast(message, 'error', undefined),
warning: (message: string) => addToast(message, 'warning', undefined),
info: (message: string) => addToast(message, 'info', undefined),
}),
}));
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const TRIP_INITIAL = useTripStore.getState();
const PANTHEON: AssistantChangeEffect = {
kind: 'add',
dayId: 3,
fromDayId: null,
placeId: null,
assignmentId: null,
place: { name: 'Pantheon', lat: 48.02, lng: 2.0, categoryId: null },
};
function preview(toolCallId: string, subject: string): AssistantChangePreview {
return {
toolCallId,
verbKey: 'addPlace',
subject,
rows: [
{ op: 'add', labelKey: 'address', before: null, after: 'Kyoto' },
{ op: 'change', labelKey: 'time', before: '09:00', after: '10:30' },
],
dayId: 3,
placeIds: [],
undoable: true,
};
}
const pending = (id: string, subject: string): ChangeItem => ({
toolCallId: id,
preview: preview(id, subject),
applied: null,
state: 'pending',
});
const onTheMap = (id: string, subject: string, effect = PANTHEON): ChangeItem => ({
...pending(id, subject),
preview: { ...preview(id, subject), effect },
});
function receipt(id: string, undoUntil: string | null): ChangeItem {
const applied: AssistantApplied = { toolCallId: id, entity: 'place', ids: [9], dayId: 3, undoUntil };
return { toolCallId: id, preview: preview(id, 'Kinkaku-ji'), applied, state: 'applied' };
}
function renderCard(items: ChangeItem[], view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
return render(
<AssistantViewContext.Provider value={value}>
<AssistantChangeCard items={items} />
</AssistantViewContext.Provider>
);
}
function patchChat(next: Partial<ReturnType<typeof chatOf>>) {
useAssistantStore.setState((s) => ({ byTrip: { ...s.byTrip, [TRIP]: { ...chatOf(s, TRIP), ...next } } }));
}
/** The planner holding this trip, with one stop on day 3. */
function plannerHere(tripId = TRIP) {
const louvre = buildPlace({ id: 10, name: 'Louvre', lat: 48.0, lng: 2.0 });
useTripStore.setState({
trip: buildTrip({ id: tripId }),
days: [buildDay({ id: 3, title: 'Rome' })],
assignments: { 3: [buildAssignment({ id: 100, day_id: 3, place: louvre, assignment_time: '09:00' })] },
places: [louvre],
});
}
const future = () => new Date(Date.now() + 10 * 60_000).toISOString();
const decide = vi.fn<(tripId: number, decisions: { toolCallId: string; approved: boolean }[]) => Promise<void>>();
const undo = vi.fn<(tripId: number, toolCallId: string) => Promise<'undone' | 'stale' | 'failed'>>();
const undoBlock =
vi.fn<(tripId: number, ids: readonly string[]) => Promise<{ done: number; stale: number; failed: number }>>();
const setCompare = vi.fn<(tripId: number, mode: 'before' | 'after') => void>();
const requestCamera = vi.fn();
const setMinimized = vi.fn();
beforeEach(() => {
decide.mockResolvedValue(undefined);
useAssistantStore.setState({ decide, undo, undoBlock, setCompare, requestCamera, setMinimized });
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
useTripStore.setState(TRIP_INITIAL, true);
vi.clearAllMocks();
vi.useRealTimers();
});
describe('AssistantChangeCard: deciding', () => {
it('FE-AI-CARD-001: shows what changes in plain words, field by field, before anything is written', () => {
renderCard([pending('c1', 'Kinkaku-ji')]);
expect(screen.getByText('Kinkaku-ji')).toBeInTheDocument();
expect(screen.getByText('Add place')).toBeInTheDocument();
// The faint line says when, old to new; the address is for the planner, not the card.
expect(screen.getByText('09:00')).toBeInTheDocument();
expect(screen.getByText('10:30')).toBeInTheDocument();
expect(screen.queryByText('Kyoto')).toBeNull();
expect(screen.queryByRole('button', { name: 'Include this change' })).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('1 change is waiting for you');
});
it('FE-AI-CARD-002: one change applies or discards with one press', () => {
renderCard([pending('c1', 'Kinkaku-ji')]);
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(decide).toHaveBeenLastCalledWith(TRIP, [{ toolCallId: 'c1', approved: true }]);
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(decide).toHaveBeenLastCalledWith(TRIP, [{ toolCallId: 'c1', approved: false }]);
});
it('FE-AI-CARD-003: several changes each get a switch, and the button applies the ones left on in one request', () => {
renderCard([pending('c1', 'Kinkaku-ji'), pending('c2', 'Ryoan-ji'), pending('c3', 'Nanzen-ji')]);
expect(screen.getByRole('button', { name: 'Apply 3' })).toBeEnabled();
const [, second] = screen.getAllByRole('button', { name: 'Include this change' });
fireEvent.click(second as HTMLElement);
fireEvent.click(screen.getByRole('button', { name: 'Apply 2' }));
expect(decide).toHaveBeenCalledTimes(1);
expect(decide).toHaveBeenCalledWith(TRIP, [
{ toolCallId: 'c1', approved: true },
{ toolCallId: 'c2', approved: false },
{ toolCallId: 'c3', approved: true },
]);
});
it('FE-AI-CARD-004: nothing can be decided while the assistant is still answering', () => {
patchChat({ isRunning: true });
renderCard([pending('c1', 'Kinkaku-ji')]);
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Discard' })).toBeDisabled();
});
});
describe('AssistantChangeCard: the receipt', () => {
it('FE-AI-CARD-005: an applied change shows on the map and can be undone until its window closes', () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date('2026-10-21T08:00:00.000Z'));
const onShowOnMap = vi.fn();
renderCard([receipt('c1', '2026-10-21T08:15:00.000Z')], { onShowOnMap });
expect(screen.getByRole('img', { name: 'Applied' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
expect(onShowOnMap).toHaveBeenCalledWith(expect.objectContaining({ toolCallId: 'c1', dayId: 3 }));
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(16 * 60_000);
});
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
});
it('FE-AI-CARD-006: an undone change says so, a stale one explains why it stays', async () => {
// The store marks what it took back; the card reads it from there.
undo.mockImplementationOnce(async (_tripId, toolCallId) => {
patchChat({ undone: { [toolCallId]: true } });
return 'undone';
});
const first = renderCard([receipt('c1', future())]);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
});
expect(undo).toHaveBeenCalledWith(TRIP, 'c1');
expect(screen.getByRole('img', { name: 'Undone' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
first.unmount();
undo.mockResolvedValueOnce('stale');
renderCard([receipt('c2', future())]);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
});
expect(addToast).toHaveBeenCalledWith('Changed in the meantime. Please adjust it by hand.', 'warning', undefined);
});
it('FE-AI-CARD-007: no map button where nothing can show it, and none of the receipt actions after a discard', () => {
renderCard([receipt('c1', null), { ...pending('c2', 'Ryoan-ji'), state: 'discarded' }]);
expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
expect(screen.getByRole('img', { name: 'Discarded' })).toBeInTheDocument();
});
});
describe('AssistantChangeCard: on the map', () => {
it('FE-AI-CARD-008: resting on a row lights its pin, and a lit pin lights its row', () => {
renderCard([onTheMap('c1', 'Pantheon'), pending('c2', 'Ryoan-ji')]);
fireEvent.mouseEnter(screen.getByText('Pantheon'));
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'c1', source: 'card' });
fireEvent.mouseLeave(screen.getByText('Pantheon'));
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
// A change the plan does not show has no pin to light.
fireEvent.mouseEnter(screen.getByText('Ryoan-ji'));
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
const row = screen.getByText('Pantheon').closest('[class*="rounded-[12px]"]');
expect(row).not.toBeNull();
expect(row).not.toHaveClass('bg-surface-hover');
act(() => useAssistantStore.setState({ mapLink: { tripId: TRIP, hoveredId: 'c1', source: 'map' } }));
expect(row).toHaveClass('bg-surface-hover');
});
it('FE-AI-CARD-009: the head says whether the days still fit, without drawing the days again', () => {
plannerHere();
renderCard([onTheMap('c1', 'Pantheon')]);
expect(screen.getByText(/Fits the time|to check/)).toBeInTheDocument();
// The day list of the planner shows the suggestion; the card keeps to one line per change.
expect(screen.getAllByText('Pantheon')).toHaveLength(1);
expect(screen.queryByRole('button', { name: 'Show schedule' })).toBeNull();
});
it('FE-AI-CARD-010: no check while the planner holds another trip, or once nothing waits', () => {
plannerHere(TRIP + 1);
const first = renderCard([onTheMap('c1', 'Pantheon')]);
expect(screen.queryByText(/Fits the time|to check/)).toBeNull();
first.unmount();
plannerHere();
renderCard([{ ...onTheMap('c1', 'Pantheon'), state: 'applied' }]);
expect(screen.queryByText(/Fits the time|to check/)).toBeNull();
});
it('FE-AI-CARD-011: on the phone the card hops to the map and frames what it would change', () => {
plannerHere();
const onShowOnMap = vi.fn();
const first = renderCard([onTheMap('c1', 'Pantheon')], { onShowOnMap });
expect(screen.queryByRole('button', { name: 'View on map' })).toBeNull();
first.unmount();
renderCard([onTheMap('c1', 'Pantheon')], { variant: 'fullscreen', onShowOnMap });
fireEvent.click(screen.getByRole('button', { name: 'View on map' }));
expect(requestCamera).toHaveBeenCalledWith(
TRIP,
[
[48.02, 2.0],
[48.0, 2.0],
],
'fit',
'user'
);
expect(setMinimized).toHaveBeenCalledWith(true);
});
});
describe('AssistantChangeCard: the applied block', () => {
const compare = (blockKey: string, mode: 'before' | 'after' = 'after') =>
patchChat({ compare: { blockKey, mode, snapshot: { 3: [] }, newPlaceCalls: [], addedPlaceIds: [] } });
it('FE-AI-CARD-012: before and after are there only for the block this session can compare, beside a map', () => {
compare('c1');
const onShowOnMap = vi.fn();
const first = renderCard([receipt('c1', null), receipt('c2', null)], { onShowOnMap });
expect(screen.getByRole('button', { name: 'After' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Before' }));
expect(setCompare).toHaveBeenCalledWith(TRIP, 'before');
first.unmount();
const other = renderCard([receipt('c7', null), receipt('c8', null)], { onShowOnMap });
expect(screen.queryByRole('button', { name: 'Before' })).toBeNull();
other.unmount();
renderCard([receipt('c1', null), receipt('c2', null)]);
expect(screen.queryByRole('button', { name: 'Before' })).toBeNull();
});
it('FE-AI-CARD-013: undo all takes back what still can be, and says how it went', async () => {
const ids = ['c1', 'c2', 'c3'];
const items = () => [receipt('c1', future()), receipt('c2', future()), receipt('c3', null)];
const press = async () => {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Undo all' }));
});
};
undoBlock.mockResolvedValueOnce({ done: 2, stale: 0, failed: 0 });
const first = renderCard(items());
await press();
expect(undoBlock).toHaveBeenCalledWith(TRIP, ids.slice(0, 2));
expect(addToast).toHaveBeenLastCalledWith('2 changes undone', 'success', undefined);
undoBlock.mockResolvedValueOnce({ done: 1, stale: 1, failed: 0 });
await press();
expect(addToast).toHaveBeenLastCalledWith('1 undone, 1 changed since', 'warning', undefined);
undoBlock.mockResolvedValueOnce({ done: 0, stale: 0, failed: 2 });
await press();
expect(addToast).toHaveBeenLastCalledWith('2 changes could not be undone', 'error', undefined);
first.unmount();
// One left to take back is the single undo button's job.
patchChat({ undone: { c2: true } });
renderCard(items());
expect(screen.queryByRole('button', { name: 'Undo all' })).toBeNull();
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
});
});
describe('AssistantCompareSwitch', () => {
it('follows whichever block is compared when it names none', () => {
patchChat({ compare: { blockKey: 'c5', mode: 'before', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] } });
render(<AssistantCompareSwitch tripId={TRIP} />);
expect(screen.getByRole('button', { name: 'Before' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: 'After' }));
expect(setCompare).toHaveBeenCalledWith(TRIP, 'after');
});
it("gives each side a thumb's room when tall", () => {
patchChat({ compare: { blockKey: 'c5', mode: 'after', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] } });
render(<AssistantCompareSwitch tripId={TRIP} tall />);
expect(screen.getByRole('button', { name: 'Before' }).className).toContain('h-9');
expect(screen.getByRole('button', { name: 'After' }).className).toContain('min-w-[44px]');
});
});
@@ -0,0 +1,541 @@
import type { AssistantApplied, AssistantChangePreview, AssistantChangeVerbKey } from '@trek/shared';
import {
CalendarArrowUp,
CalendarMinus,
CalendarPlus,
Check,
CircleSlash,
ListTodo,
LoaderCircle,
Luggage,
MapPin,
MapPinPlus,
NotebookPen,
PencilLine,
Plus,
StickyNote,
Ticket,
Trash2,
TriangleAlert,
Undo2,
Wallet,
} from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { blockKeyOf } from '../../store/assistantMapModel';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import { useToast } from '../shared/Toast';
import { Tooltip } from '../shared/Tooltip';
import AssistantChangeFields from './AssistantChangeFields';
import AssistantCompareSwitch from './AssistantCompareSwitch';
import AssistantFitCheck from './AssistantFitCheck';
import AssistantWeekOverview from './AssistantWeekOverview';
import { 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:
* what happens to which place, note or item, field by field. Nothing is
* written before the user applies it. Each change keeps its own decision, so
* when the card holds several, each has a switch and the button applies the
* ones left on. Once decided, the card turns into a receipt per change, with
* a way to the change on the map and, for a while, undo.
*
* Every change wears its kind: a tinted tile with the icon of what it touches
* and a badge naming the verb, in the tone of what happens (green adds, blue
* changes, amber moves, red takes away). Its fields sit as badges holding a
* label badge, so a row of them reads at a glance.
*
* While it waits, the card shows the days it touches as they would read
* afterwards, and each row lights its pin on the map (and the pin its row).
* Once applied, the head offers the plan before and after the block on the
* map, and taking the whole block back at once.
*/
type Tone = 'success' | 'info' | 'warning' | 'danger';
const TILE: Record<Tone, string> = {
success: 'bg-success-soft text-success',
info: 'bg-info-soft text-info',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
};
/** What each kind of change looks like. A table, so a new verb fails to compile until it has a look. */
const VERB_LOOK: Record<AssistantChangeVerbKey, { Icon: typeof Check; tone: Tone }> = {
addPlace: { Icon: MapPinPlus, tone: 'success' },
addPlaceToDay: { Icon: CalendarPlus, tone: 'success' },
updatePlace: { Icon: PencilLine, tone: 'info' },
assignPlace: { Icon: CalendarPlus, tone: 'success' },
moveAssignment: { Icon: CalendarArrowUp, tone: 'warning' },
removeFromDay: { Icon: CalendarMinus, tone: 'danger' },
addDayNote: { Icon: StickyNote, tone: 'success' },
updateDayNote: { Icon: StickyNote, tone: 'info' },
addPackingItem: { Icon: Luggage, tone: 'success' },
updatePackingItem: { Icon: Luggage, tone: 'info' },
addTodo: { Icon: ListTodo, tone: 'success' },
addBudgetItem: { Icon: Wallet, tone: 'success' },
updateBudgetItem: { Icon: Wallet, tone: 'info' },
addReservation: { Icon: Ticket, tone: 'success' },
importBooking: { Icon: Ticket, tone: 'success' },
rememberNote: { Icon: NotebookPen, tone: 'info' },
genericCreate: { Icon: Plus, tone: 'success' },
genericUpdate: { Icon: PencilLine, tone: 'info' },
genericRemove: { Icon: Trash2, tone: 'danger' },
};
/** How a decided change reads: one quiet mark, its words in the tooltip. */
const RECEIPT: Record<Exclude<ChangeState, 'pending'>, { labelKey: string; Icon: typeof Check; tone: string }> = {
applying: { labelKey: 'assistant.change.applying', Icon: LoaderCircle, tone: 'text-content-muted' },
applied: { labelKey: 'assistant.change.applied', Icon: Check, tone: 'text-success' },
failed: { labelKey: 'assistant.change.failed', Icon: TriangleAlert, tone: 'text-danger' },
discarded: { labelKey: 'assistant.change.discarded', Icon: CircleSlash, tone: 'text-content-faint' },
expired: { labelKey: 'assistant.change.expired', Icon: CircleSlash, tone: 'text-content-faint' },
};
const ICON_BUTTON =
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-50';
function KindTile({
verbKey,
size = 'md',
dimmed = false,
}: {
verbKey: AssistantChangeVerbKey | null;
size?: 'sm' | 'md';
dimmed?: boolean;
}) {
const box = size === 'sm' ? 'h-7 w-7 rounded-md' : 'h-8 w-8 rounded-lg';
if (!verbKey) return <span className={`trek-skeleton flex-shrink-0 ${box}`} aria-hidden="true" />;
const { Icon, tone } = VERB_LOOK[verbKey];
return (
<span
aria-hidden="true"
className={`flex flex-shrink-0 items-center justify-center ${box} ${dimmed ? 'bg-surface-secondary text-content-faint' : TILE[tone]}`}
>
<Icon size={size === 'sm' ? 14 : 16} strokeWidth={2.1} />
</span>
);
}
/** The key of a tool's own action name, for the tools TREK names one for. */
const toolKey = (tool: string) => `assistant.tool.${tool}`;
/** What the change does: the tool's own action where TREK names it, else add, change or remove. */
function verbLabel(preview: AssistantChangePreview, t: (key: string) => string): string {
const own = preview.tool ? toolKey(preview.tool) : null;
const named = own ? t(own) : null;
return named && named !== own ? named : t(`assistant.change.verb.${preview.verbKey}`);
}
function ChangeHead({ preview, struck = false }: { preview: AssistantChangePreview | null; struck?: boolean }) {
const { t } = useTranslation();
if (!preview) return <span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />;
return (
<span className="flex min-w-0 flex-col">
<bdi className={`truncate text-body font-semibold leading-tight text-content ${struck ? 'line-through' : ''}`}>
{preview.subject}
</bdi>
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
<span className="truncate">{verbLabel(preview, t)}</span>
{preview.domain && (
// Where in TREK a change outside the trip plan lands: the vacation planner, the journal.
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
{t(`assistant.domain.${preview.domain}`)}
</span>
)}
</span>
</span>
);
}
/** The time now, ticking once more when the next undo window closes. */
function useUndoClock(items: ChangeItem[]): number {
const [now, setNow] = useState(() => Date.now());
const next = Math.min(...items.map((i) => undoLeftMs(i.applied, now)).filter((ms) => ms > 0));
useEffect(() => {
if (!Number.isFinite(next)) return;
const timer = window.setTimeout(() => setNow(Date.now()), next + 50);
return () => window.clearTimeout(timer);
}, [next]);
return now;
}
/** Whether an applied change can still be taken back now. */
function undoableNow(item: ChangeItem, undone: Readonly<Record<string, true>>, now: number): boolean {
return (
item.state === 'applied' &&
!undone[item.toolCallId] &&
!!item.preview?.undoable &&
undoLeftMs(item.applied, now) > 0
);
}
/** The waiting changes the user left on that touch the day plan. */
function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): DayChange[] {
return items.flatMap((i) =>
i.state === 'pending' && i.preview?.effect && !excluded.has(i.toolCallId)
? [{ toolCallId: i.toolCallId, effect: i.preview.effect, subject: i.preview.subject }]
: []
);
}
/** What an applied change can be shown as on the map; a kept note, a todo or an expense has no place there. */
const ON_MAP: ReadonlySet<AssistantApplied['entity']> = new Set(['place', 'assignment', 'day_note', 'reservation']);
function Receipt({
item,
undone,
canUndo,
onUndo,
}: {
item: ChangeItem;
undone: boolean;
canUndo: boolean;
onUndo: () => void;
}) {
const { t } = useTranslation();
const { onShowOnMap } = useAssistantView();
if (item.state === 'pending') return null;
const receipt = undone ? RECEIPT.discarded : RECEIPT[item.state];
const { Icon } = receipt;
const applied = item.state === 'applied' && !undone ? item.applied : null;
const mappable = applied !== null && ON_MAP.has(applied.entity);
const settled = item.state === 'discarded' || item.state === 'expired' || undone;
const statusLabel = t(undone ? 'assistant.change.undone' : receipt.labelKey);
return (
<div className="trek-assistant-enter flex items-center gap-2.5 py-1.5">
<KindTile verbKey={item.preview?.verbKey ?? null} size="sm" dimmed={settled} />
<div className={`min-w-0 flex-1 ${settled ? 'opacity-60' : ''}`}>
<ChangeHead preview={item.preview} struck={settled} />
</div>
{applied && mappable && onShowOnMap && (
<Tooltip label={t('assistant.change.showOnMap')} placement="top">
<button
type="button"
onClick={() => onShowOnMap(applied)}
aria-label={t('assistant.change.showOnMap')}
className={ICON_BUTTON}
>
<MapPin size={14} aria-hidden="true" />
</button>
</Tooltip>
)}
{canUndo && (
<Tooltip label={t('assistant.change.undo')} placement="top">
<button type="button" onClick={onUndo} aria-label={t('assistant.change.undo')} className={ICON_BUTTON}>
<Undo2 size={14} aria-hidden="true" />
</button>
</Tooltip>
)}
<Tooltip label={statusLabel} placement="top">
<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>
</div>
);
}
/**
* A change waiting for its decision, as a row of the planner's day list: the
* round choice in front (only when there is more than one to choose from),
* what it touches in bold, and a faint line saying what happens and when.
* Resting on it lights its pin on the map.
*/
function PendingRow({
item,
excluded,
withChoice,
disabled,
onToggle,
}: {
item: ChangeItem;
excluded: boolean;
withChoice: boolean;
disabled: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const { litFromMap, handlers } = useChangeHover(tripId, item.preview?.effect ? item.toolCallId : null);
const preview = item.preview;
return (
<div
{...handlers}
className={`-mx-1.5 flex items-center gap-2.5 rounded-[12px] px-1.5 py-2 transition-[opacity,background-color] ${
excluded ? 'opacity-50' : ''
} ${litFromMap ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
>
{withChoice && (
<button
type="button"
onClick={onToggle}
disabled={disabled}
aria-pressed={!excluded}
aria-label={t('assistant.change.include')}
className={`grid h-[18px] w-[18px] flex-none place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 ${
excluded ? 'border-[1.5px] border-edge bg-surface-card' : 'bg-accent'
}`}
>
{!excluded && <Check size={11} strokeWidth={3} aria-hidden="true" className="text-accent-text" />}
</button>
)}
<KindTile verbKey={preview?.verbKey ?? null} size="sm" dimmed={excluded} />
<div className="min-w-0 flex-1">
{preview ? (
<>
<bdi className="block truncate text-body font-semibold leading-tight text-content">{preview.subject}</bdi>
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
<span className="flex-shrink-0">{verbLabel(preview, t)}</span>
{preview.domain && (
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
{t(`assistant.domain.${preview.domain}`)}
</span>
)}
<AssistantChangeFields rows={preview.rows} />
</span>
</>
) : (
<span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />
)}
</div>
</div>
);
}
/** The card's head: what the card is, what can be done with it as a whole, and how many changes it holds. */
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
const { t } = useTranslation();
return (
<div className="mb-1 flex min-h-7 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-content-secondary">
{t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
</span>
{actions}
<span className="text-caption tabular-nums text-content-faint">{count}</span>
</div>
);
}
/** On the phone the map hides behind the chat: this shrinks the chat and frames what the card would change. */
function ViewOnMapButton({ changes }: { changes: readonly DayChange[] }) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const requestCamera = useAssistantStore((s) => s.requestCamera);
const setMinimized = useAssistantStore((s) => s.setMinimized);
const show = () => {
const { assignments, places } = useTripStore.getState();
requestCamera(tripId, changePoints(changes, assignments, places), 'fit', 'user');
setMinimized(true);
};
return (
<Tooltip label={t('assistant.change.viewOnMap')} placement="top">
<button type="button" onClick={show} aria-label={t('assistant.change.viewOnMap')} className={ICON_BUTTON}>
<MapPin size={14} aria-hidden="true" />
</button>
</Tooltip>
);
}
/** Takes back every change of the block that still can be, the last one first, and says how it went. */
function UndoAllButton({ toolCallIds }: { toolCallIds: readonly string[] }) {
const { t } = useTranslation();
const toast = useToast();
const { tripId } = useAssistantView();
const undoBlock = useAssistantStore((s) => s.undoBlock);
const [busy, setBusy] = useState(false);
const run = async () => {
setBusy(true);
try {
const { done, stale, failed } = await undoBlock(tripId, toolCallIds);
if (failed > 0) toast.error(t('assistant.change.undoAllFailed', { count: failed }));
else if (stale > 0) toast.warning(t('assistant.change.undoAllPartial', { done, stale }));
else toast.success(t('assistant.change.undoneAll', { count: done }));
} finally {
setBusy(false);
}
};
return (
<Tooltip label={t('assistant.change.undoAll')} placement="top">
<button
type="button"
disabled={busy}
onClick={() => void run()}
aria-label={t('assistant.change.undoAll')}
className={ICON_BUTTON}
>
{busy ? (
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
) : (
<Undo2 size={14} aria-hidden="true" />
)}
</button>
</Tooltip>
);
}
/** What the head offers: on a waiting card the way to the map on the phone, on a receipt the block as a whole. */
function HeadActions({
open,
items,
changes,
undoable,
}: {
open: boolean;
items: readonly ChangeItem[];
changes: readonly DayChange[];
undoable: readonly string[];
}) {
const { tripId, variant, onShowOnMap } = useAssistantView();
const here = useTripStore((s) => s.trip?.id === tripId);
if (open) {
const toMap = variant === 'fullscreen' && !!onShowOnMap && here && changes.length > 0;
return (
<>
<AssistantFitCheck changes={changes} />
{toMap && <ViewOnMapButton changes={changes} />}
</>
);
}
return (
<>
{onShowOnMap && <AssistantCompareSwitch tripId={tripId} blockKey={blockKeyOf(items.map((i) => i.toolCallId))} />}
{undoable.length >= 2 && <UndoAllButton toolCallIds={undoable} />}
</>
);
}
export default function AssistantChangeCard({ items }: { items: ChangeItem[] }) {
const { t } = useTranslation();
const toast = useToast();
const { tripId } = useAssistantView();
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
const undone = useAssistantStore((s) => chatOf(s, tripId).undone);
const decide = useAssistantStore((s) => s.decide);
const undo = useAssistantStore((s) => s.undo);
const [excluded, setExcluded] = useState<ReadonlySet<string>>(() => new Set());
const now = useUndoClock(items);
const pending = items.filter((i) => i.state === 'pending');
const chosen = pending.filter((i) => !excluded.has(i.toolCallId)).length;
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);
if (!next.delete(toolCallId)) next.add(toolCallId);
return next;
});
const submit = (applyChosen: boolean) =>
decide(
tripId,
pending.map((i) => ({ toolCallId: i.toolCallId, approved: applyChosen && !excluded.has(i.toolCallId) }))
);
// An undone change reads as undone from the store; only what stays needs a word.
const runUndo = async (toolCallId: string) => {
const outcome = await undo(tripId, toolCallId);
if (outcome === 'stale') toast.warning(t('assistant.change.undoStale'));
else if (outcome === 'failed') toast.error(t('assistant.change.undoFailed'));
};
const open = pending.length > 0;
return (
<section className="trek-popover-enter rounded-xl border border-edge-faint bg-surface-card px-3 pb-2 pt-2.5 shadow-card">
<CardHead
open={open}
count={items.length}
actions={<HeadActions open={open} items={items} changes={changes} undoable={undoable} />}
/>
{open && week.length > 0 && (
<AssistantWeekOverview days={week} excluded={excluded} disabled={isRunning} onToggleDay={toggleDay} />
)}
{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>
{items.map((item) =>
item.state === 'pending' ? (
folded ? null : (
<PendingRow
key={item.toolCallId}
item={item}
excluded={excluded.has(item.toolCallId)}
withChoice={pending.length > 1}
disabled={isRunning}
onToggle={() => toggle(item.toolCallId)}
/>
)
) : (
<Receipt
key={item.toolCallId}
item={item}
undone={!!undone[item.toolCallId]}
canUndo={undoable.includes(item.toolCallId)}
onUndo={() => void runUndo(item.toolCallId)}
/>
)
)}
</div>
{open && (
<>
<p className="sr-only" role="status">
{t('assistant.change.pending', { count: pending.length })}
</p>
<div className="flex justify-end gap-2 border-t border-edge-faint pt-2.5">
<button
type="button"
disabled={isRunning}
onClick={() => void submit(false)}
className="rounded-lg px-3 py-1.5 text-body text-content-secondary hover:bg-surface-hover hover:text-content disabled:opacity-50"
>
{t('assistant.change.discard')}
</button>
<button
type="button"
disabled={isRunning || chosen === 0}
onClick={() => void submit(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-body font-medium text-accent-text hover:bg-accent-hover disabled:opacity-50"
>
<Check size={14} aria-hidden="true" />
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
</button>
</div>
</>
)}
</section>
);
}
@@ -0,0 +1,75 @@
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
import {
ArrowRight,
CalendarClock,
CalendarDays,
Clock,
Hash,
ListOrdered,
LogIn,
LogOut,
type LucideIcon,
Tag,
Wallet,
} from 'lucide-react';
import { useTranslation } from '../../i18n';
/**
* The few facts that tell a change apart at a glance, on the faint line under
* its name the way the planner's day list shows a place: when, what kind,
* what it costs, a new order. A value that changes reads old to new. The rest
* of what the change sets is not listed: the planner shows it once applied.
*/
/** The fields worth a place on the line, each with its glyph, in this order. */
const KEY_FIELDS: Partial<Record<AssistantChangeLabelKey, LucideIcon>> = {
day: CalendarDays,
time: Clock,
endTime: Clock,
checkIn: LogIn,
checkOut: LogOut,
dueDate: CalendarClock,
category: Tag,
price: Wallet,
quantity: Hash,
order: ListOrdered,
};
const KEY_ORDER = Object.keys(KEY_FIELDS) as AssistantChangeLabelKey[];
const MAX_FACTS = 3;
/** "#3" is how a day comes from the server; it reads as "Day 3". */
function shown(row: AssistantChangeRow, value: string | null | undefined, dayWord: string): string {
if (row.labelKey === 'day' && value && /^#\d+$/.test(value)) return `${dayWord} ${value.slice(1)}`;
return value ?? '';
}
export default function AssistantChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
const { t } = useTranslation();
const dayWord = t('assistant.week.day');
const facts = rows
.filter((row) => row.labelKey in KEY_FIELDS && row.after)
.sort((a, b) => KEY_ORDER.indexOf(a.labelKey) - KEY_ORDER.indexOf(b.labelKey))
.slice(0, MAX_FACTS);
if (facts.length === 0) return null;
return (
<span className="flex min-w-0 items-center gap-2.5">
{facts.map((row, i) => {
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
const changed = row.op !== 'add' && !!row.before;
return (
<span key={`${row.labelKey}-${i}`} className="inline-flex min-w-0 items-center gap-1">
<Icon size={11} aria-hidden="true" className="flex-shrink-0" />
{changed && (
<>
<bdi className="truncate line-through">{shown(row, row.before, dayWord)}</bdi>
<ArrowRight size={10} aria-hidden="true" className="flex-shrink-0 rtl:-scale-x-100" />
</>
)}
<bdi className="truncate text-content-muted">{shown(row, row.after, dayWord)}</bdi>
</span>
);
})}
</span>
);
}
@@ -0,0 +1,58 @@
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { Tooltip } from '../shared/Tooltip';
/**
* "Before" and "after" of the block of changes applied last, as a small
* segmented switch: "before" lays the days it touched back the way they were
* on the map, "after" shows the plan as it is. Only there while this session
* holds the plan from before the block ran; a thread opened after a reload has
* no switch. Without a `blockKey` it follows whichever block is compared.
* `tall` gives each side a thumb's room, for the phone, where a tooltip
* would never show.
*/
const MODES = [
{ mode: 'before', labelKey: 'assistant.change.before' },
{ mode: 'after', labelKey: 'assistant.change.after' },
] as const;
interface AssistantCompareSwitchProps {
tripId: number;
blockKey?: string;
tall?: boolean;
}
export default function AssistantCompareSwitch({ tripId, blockKey, tall = false }: AssistantCompareSwitchProps) {
const { t } = useTranslation();
const current = useAssistantStore((s) => {
const compare = chatOf(s, tripId).compare;
return compare && (blockKey === undefined || compare.blockKey === blockKey) ? compare.mode : null;
});
const setCompare = useAssistantStore((s) => s.setCompare);
if (!current) return null;
const label = t('assistant.change.compare');
const group = (
<div role="group" aria-label={label} className="inline-flex flex-shrink-0 rounded-lg bg-surface-secondary p-0.5">
{MODES.map(({ mode, labelKey }) => (
<button
key={mode}
type="button"
aria-pressed={current === mode}
onClick={() => setCompare(tripId, mode)}
className={`rounded-md text-caption font-medium transition-colors ${
tall ? 'h-9 min-w-[44px] px-3' : 'px-2 py-0.5'
} ${current === mode ? 'bg-surface-card text-content shadow-card' : 'text-content-muted hover:text-content'}`}
>
{t(labelKey)}
</button>
))}
</div>
);
if (tall) return group;
return (
<Tooltip label={label} placement="top">
{group}
</Tooltip>
);
}
@@ -0,0 +1,165 @@
/**
* FE-AI-MENTION-001 to FE-AI-MENTION-007
*
* `@` in the composer on the real @assistant-ui runtime: the list of the
* trip's days, places and bookings opens only at the start of a word, Enter
* picks from it instead of sending, Escape closes it and keeps the text, and
* a pick leaves `@label` in the text, with no chip above it: the text is what
* the question names.
*/
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildDay, buildPlace, buildReservation, buildTrip } from '../../../tests/helpers/factories';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import AssistantSurface from './AssistantSurface';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const INITIAL_TRIP = useTripStore.getState();
const STATUS: AiStatusResponse = {
available: true,
policy: 'instance',
canConnectOwn: false,
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
own: null,
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
quota: null,
privacyAcked: true,
retention: { mode: 'days', days: 90 },
openrouterOAuth: false,
};
const send = vi.fn(async () => {});
function seed(tripInPlanner = TRIP) {
useAssistantStore.setState({
status: STATUS,
send,
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages: [] } },
});
useTripStore.setState({
trip: buildTrip({ id: tripInPlanner, title: 'Paris' }),
days: [buildDay({ id: 11, title: 'Arrival', date: '2026-11-02' }), buildDay({ id: 12, title: null, date: null })],
places: [buildPlace({ id: 21, name: 'Louvre' }), buildPlace({ id: 22, name: 'Bouillon Pigalle' })],
reservations: [buildReservation({ id: 31, title: 'Dinner at Le Train Bleu', day_id: 11 })],
});
}
async function input(): Promise<HTMLTextAreaElement> {
render(<AssistantSurface tripId={TRIP} tripTitle="Paris" variant="window" />);
return (await screen.findByRole('textbox', { name: 'Ask anything about Paris' })) as HTMLTextAreaElement;
}
const mentions = () => chatOf(useAssistantStore.getState(), TRIP).mentions;
beforeAll(() => {
// jsdom lays nothing out and has no element scrolling; the viewport only needs the call to exist.
Element.prototype.scrollTo = vi.fn() as unknown as typeof Element.prototype.scrollTo;
});
beforeEach(() => send.mockClear());
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
useTripStore.setState(INITIAL_TRIP, true);
});
describe('AssistantComposer mentions', () => {
it('FE-AI-MENTION-001: typing after @ lists matches, and Enter picks one instead of sending', async () => {
seed();
const field = await input();
expect(screen.getByText('Type @ to mention days, places and bookings.')).toBeInTheDocument();
fireEvent.change(field, { target: { value: 'Is @lou' } });
expect(await screen.findByRole('option', { name: 'Louvre' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Bouillon Pigalle' })).toBeNull();
fireEvent.keyDown(field, { key: 'Enter' });
await waitFor(() => expect(field.value).toBe('Is @Louvre '));
expect(send).not.toHaveBeenCalled();
// A browser reports where the caret went after the pick; jsdom does not on its own.
fireEvent.select(field);
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
expect(mentions()).toEqual([{ kind: 'place', id: 21, label: 'Louvre' }]);
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Is @Louvre ');
// The pick shows in the text only, never as a chip above it.
expect(screen.queryByRole('button', { name: 'Remove context' })).toBeNull();
fireEvent.change(field, { target: { value: 'Is @Louvre open late?' } });
fireEvent.keyDown(field, { key: 'Enter' });
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Is @Louvre open late?'));
});
it('FE-AI-MENTION-002: a bare @ offers the kinds, and a kind opens its entries', async () => {
seed();
const field = await input();
fireEvent.change(field, { target: { value: '@' } });
expect(await screen.findByRole('option', { name: 'Days' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Places' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Bookings' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('option', { name: 'Days' }));
expect(await screen.findByRole('option', { name: /Arrival/ })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Day 2' })).toBeInTheDocument();
// The heading leads back to the kinds.
fireEvent.click(screen.getByRole('button', { name: 'Days' }));
expect(await screen.findByRole('option', { name: 'Places' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('option', { name: 'Days' }));
fireEvent.click(await screen.findByRole('option', { name: /Arrival/ }));
await waitFor(() => expect(field.value).toBe('@Arrival '));
expect(mentions()).toEqual([{ kind: 'day', id: 11, label: 'Arrival' }]);
});
it('FE-AI-MENTION-003: Escape closes the list and keeps what was typed', async () => {
seed();
const field = await input();
fireEvent.change(field, { target: { value: 'near @bou' } });
expect(await screen.findByRole('listbox')).toBeInTheDocument();
fireEvent.keyDown(field, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
expect(field.value).toBe('near @bou');
expect(mentions()).toEqual([]);
});
it('FE-AI-MENTION-004: @ inside a word, such as an email address, opens nothing', async () => {
seed();
const field = await input();
fireEvent.change(field, { target: { value: 'mail@lou' } });
expect(screen.queryByRole('listbox')).toBeNull();
});
it('FE-AI-MENTION-005: says so when nothing matches', async () => {
seed();
const field = await input();
fireEvent.change(field, { target: { value: '@zzz' } });
expect(await screen.findByText('Nothing found')).toBeInTheDocument();
});
it('FE-AI-MENTION-006: a mention is taken back by deleting its @label, with no chip row to keep in step', async () => {
seed();
const field = await input();
fireEvent.change(field, { target: { value: 'Dinner after @Din' } });
fireEvent.click(await screen.findByRole('option', { name: /Dinner at Le Train Bleu/ }));
await waitFor(() => expect(field.value).toBe('Dinner after @Dinner at Le Train Bleu '));
expect(screen.queryByRole('button', { name: 'Remove context' })).toBeNull();
fireEvent.change(field, { target: { value: 'Dinner after ' } });
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Dinner after ');
expect(screen.queryByText('Dinner at Le Train Bleu')).toBeNull();
});
it('FE-AI-MENTION-007: for a trip the planner does not show, @ stays plain text', async () => {
seed(99);
const field = await input();
fireEvent.change(field, { target: { value: '@lou' } });
expect(screen.queryByRole('listbox')).toBeNull();
expect(screen.queryByText('Type @ to mention days, places and bookings.')).toBeNull();
});
});
@@ -0,0 +1,185 @@
import { AuiIf, ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react';
import { ArrowUp, CalendarDays, MapPin, Paperclip, Square, X } from 'lucide-react';
import { type ReactNode, useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import PlaceAvatar from '../shared/PlaceAvatar';
import Tooltip from '../shared/Tooltip';
import AssistantMentionPopover from './AssistantMentionPopover';
import { useAssistantView } from './assistantViewContext';
import { useAssistantAttach } from './useAssistantAttach';
import { useAssistantFocus } from './useAssistantFocus';
import { useAssistantMentions } from './useAssistantMentions';
import { useAssistantSuggestions } from './useAssistantSuggestions';
import { useTypewriterPlaceholder } from './useTypewriterPlaceholder';
/** Send and Stop share one spot; on the phone it is a full thumb's size. */
const ACTION = {
window: 'h-9 w-9',
fullscreen: 'h-11 w-11',
} as const;
/**
* Where the user writes. One rounded shell with the text and, in the same
* spot, Send or Stop. Enter sends, Shift+Enter breaks the line, Escape stops
* a running answer. The one chip above the text is the focus the question is
* about, and can be removed. `@` at the start of a word opens the trip's days,
* places and bookings to name; a pick stays as `@label` in the text, which is
* what the question names, and shows as a chip only in the sent bubble. While
* that list is open, Enter picks and Escape closes it. While the field is empty,
* its placeholder writes out things worth asking about this trip, one after
* another.
*
* The text typed so far lives in the assistant store too, so it survives the
* window closing or the phone hopping to the map and back.
*/
export default function AssistantComposer({ tripId, tripTitle }: { tripId: number; tripTitle: string }) {
const { t } = useTranslation();
const { variant } = useAssistantView();
const aui = useAui();
const setDraft = useAssistantStore((s) => s.setDraft);
const { label, kind, place, clear } = useAssistantFocus(tripId);
const mentions = useAssistantMentions(tripId);
const { suggestions } = useAssistantSuggestions(tripId);
const empty = useAuiState((s) => s.composer.isEmpty);
const { take, busy, accept } = useAssistantAttach(tripId);
const fileInput = useRef<HTMLInputElement>(null);
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
const placeholder = useTypewriterPlaceholder(
suggestions.map((s) => s.prompt),
fallback,
empty
);
// Takes back what was typed before the chat was last shown.
useEffect(() => {
const draft = chatOf(useAssistantStore.getState(), tripId).draft;
if (draft) aui.composer.setText(draft);
}, [aui, tripId]);
const FocusIcon = kind === 'place' ? MapPin : CalendarDays;
return (
<ComposerPrimitive.Root className="relative rounded-2xl border border-edge-faint bg-surface-card shadow-card transition-colors focus-within:border-edge">
<ComposerPrimitive.TriggerPopoverRoot>
<AssistantMentionPopover adapter={mentions.adapter} onInserted={mentions.onInserted} />
{label && (
<div className="flex flex-wrap gap-1 px-2 pt-2">
<ContextChip
label={label}
icon={place ? <PlaceAvatar place={place} size={18} /> : <FocusIcon size={12} aria-hidden="true" />}
round={!!place}
onRemove={clear}
/>
</div>
)}
<div className="flex items-center gap-1 p-1.5">
<Tooltip label={t('assistant.attach.button')} placement="top">
<button
type="button"
disabled={busy}
onClick={() => fileInput.current?.click()}
aria-label={t('assistant.attach.button')}
className={`flex ${ACTION[variant]} flex-shrink-0 items-center justify-center rounded-full text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40`}
>
<Paperclip size={17} aria-hidden="true" />
</button>
</Tooltip>
<input
ref={fileInput}
type="file"
accept={accept}
hidden
onChange={(e) => {
take(e.target.files);
e.target.value = '';
}}
/>
<ComposerPrimitive.Input
// The desktop window opens ready to type; on the phone the keyboard waits for a tap.
autoFocus={variant === 'window'}
dir="auto"
minRows={1}
maxRows={8}
submitMode="enter"
addAttachmentOnPaste={false}
enterKeyHint="send"
aria-label={fallback}
placeholder={placeholder}
onChange={(e) => setDraft(tripId, e.target.value)}
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 dark:bg-transparent"
/>
<AuiIf condition={(s) => s.thread.isRunning}>
<ComposerPrimitive.Cancel
aria-label={t('assistant.composer.stop')}
className={`flex ${ACTION[variant]} flex-shrink-0 items-center justify-center rounded-full bg-accent text-accent-text transition-transform hover:scale-105 hover:bg-accent-hover active:scale-95`}
>
<Square size={12} fill="currentColor" aria-hidden="true" />
</ComposerPrimitive.Cancel>
</AuiIf>
<AuiIf condition={(s) => !s.thread.isRunning}>
<SendButton size={ACTION[variant]} />
</AuiIf>
</div>
</ComposerPrimitive.TriggerPopoverRoot>
</ComposerPrimitive.Root>
);
}
/** One thing the question is about, above the text, with the cross that takes it back. */
function ContextChip({
label,
icon,
round,
onRemove,
}: {
label: string;
icon: ReactNode;
/** A place's picture fills the chip's round end, so the chip starts closer to it. */
round: boolean;
onRemove: () => void;
}) {
const { t } = useTranslation();
return (
<span
className={`inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-full bg-surface-secondary py-0.5 pe-1 text-caption text-content-secondary ${round ? 'ps-0.5' : 'ps-2'}`}
>
<span className="flex flex-shrink-0">{icon}</span>
<bdi className="truncate">{label}</bdi>
<button
type="button"
onClick={onRemove}
aria-label={t('assistant.context.remove')}
className="rounded-full p-0.5 text-content-faint hover:bg-surface-hover hover:text-content"
>
<X size={12} aria-hidden="true" />
</button>
</span>
);
}
function SendButton({ size }: { size: string }) {
const { t } = useTranslation();
// Sending waits while a proposed change is undecided; the tooltip says so.
const blocked = useAuiState((s) => s.thread.isSendDisabled);
const button = (
<ComposerPrimitive.Send
aria-label={t('assistant.composer.send')}
className={`flex ${size} flex-shrink-0 items-center justify-center rounded-full bg-accent text-accent-text shadow-card transition-[transform,opacity,box-shadow] duration-150 hover:scale-105 hover:bg-accent-hover hover:shadow-dropdown active:scale-95 disabled:scale-100 disabled:opacity-30 disabled:shadow-none`}
>
<ArrowUp size={17} strokeWidth={2.4} aria-hidden="true" />
</ComposerPrimitive.Send>
);
// A disabled button gets no pointer events, so the tooltip that says why hangs on a wrapper.
return blocked ? (
<Tooltip label={t('assistant.composer.decideFirst')} placement="top">
<span className="inline-flex">{button}</span>
</Tooltip>
) : (
button
);
}
@@ -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,135 @@
/**
* FE-AI-ERR-001 to FE-AI-ERR-008
*
* The error line: one sentence, at most one thing to do, and that thing
* does what it says.
*/
import type { AiStatusResponse, AssistantErrorCode } from '@trek/shared';
import { Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { setForcedOffline } from '../../sync/networkMode';
import AssistantError from './AssistantError';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const STATUS: AiStatusResponse = {
available: true,
policy: 'instance_or_user',
canConnectOwn: true,
effective: { source: 'user', kind: 'anthropic', model: 'claude', host: 'api.anthropic.com', paidBy: 'user' },
own: null,
instance: null,
quota: null,
privacyAcked: true,
retention: { mode: 'days', days: 90 },
openrouterOAuth: true,
};
const retry = vi.fn(async () => {});
const continueHere = vi.fn(async () => {});
const send = vi.fn(async () => {});
function withError(error: AssistantErrorCode | null, status: AiStatusResponse = STATUS) {
useAssistantStore.setState({
status,
retry,
continueHere,
send,
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), error } },
});
}
function renderLine() {
return render(
<Routes>
<Route path="/trips/4" element={<AssistantError tripId={TRIP} />} />
<Route path="/settings" element={<p>settings page</p>} />
</Routes>,
{ initialEntries: ['/trips/4'] }
);
}
beforeEach(() => setForcedOffline(false));
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
setForcedOffline(false);
vi.clearAllMocks();
});
describe('AssistantError', () => {
it('FE-AI-ERR-001: shows nothing while all is well', () => {
withError(null);
const { container } = renderLine();
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-ERR-002: names the provider and retries a provider that was down', () => {
withError('provider_unavailable');
renderLine();
expect(screen.getByRole('status')).toHaveTextContent('Anthropic isn’t responding right now.');
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(retry).toHaveBeenCalledWith(TRIP);
});
it('FE-AI-ERR-003: a busy chat continues here, and a long run goes on with "Continue"', () => {
withError('busy');
const { unmount } = renderLine();
fireEvent.click(screen.getByRole('button', { name: 'Continue here' }));
expect(continueHere).toHaveBeenCalledWith(TRIP);
unmount();
withError('max_steps');
renderLine();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(send).toHaveBeenCalledWith(TRIP, 'Continue');
});
it('FE-AI-ERR-004: an expired sign-in leads straight to the settings card', () => {
withError('needs_reauth');
renderLine();
fireEvent.click(screen.getByRole('button', { name: 'Sign in again' }));
expect(screen.getByText('settings page')).toBeInTheDocument();
});
it('FE-AI-ERR-006: an own key that stopped working says so before anything is sent', () => {
withError(null, {
...STATUS,
own: {
scope: 'user',
kind: 'anthropic',
model: 'claude',
hint: '…a1b2',
status: 'error',
host: 'api.anthropic.com',
},
});
renderLine();
fireEvent.click(screen.getByRole('button', { name: 'Replace key' }));
expect(screen.getByText('settings page')).toBeInTheDocument();
});
it('FE-AI-ERR-007: a provider limit names the provider, without guessing at its plan', () => {
withError('rate_limited');
renderLine();
expect(screen.getByRole('status')).toHaveTextContent('Limit reached at Anthropic. Try again later.');
expect(screen.queryByRole('button')).toBeNull();
});
it('FE-AI-ERR-008: waiting changes ask for a decision before a place can be suggested', () => {
withError('decide_first');
renderLine();
expect(screen.getByRole('status')).toHaveTextContent('Decide on the open suggestions first.');
});
it('FE-AI-ERR-005: offline wins over any other error and offers nothing to press', () => {
withError('provider_unavailable');
setForcedOffline(true);
renderLine();
expect(screen.getByRole('status')).toHaveTextContent('Offline. The assistant needs internet.');
expect(screen.queryByRole('button')).toBeNull();
});
});
@@ -0,0 +1,62 @@
import { CircleAlert, WifiOff } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { type AssistantErrorActionKind, connectionErrorOf, errorView } from './assistantModel';
import { AI_SETTINGS_PATH } from './assistantParts';
import { CHATGPT_USAGE_URL, providerLabel } from './providerCatalog';
/**
* Why the assistant cannot go on, as one line above the composer with at
* most one thing to do about it: try again, continue, sign in again, and so
* on. Offline needs no button; the line says why the composer is off.
*/
export default function AssistantError({ tripId }: { tripId: number }) {
const { t } = useTranslation();
const navigate = useNavigate();
const { offline } = useNetworkMode();
const error = useAssistantStore((s) => chatOf(s, tripId).error);
const status = useAssistantStore((s) => s.status);
const retry = useAssistantStore((s) => s.retry);
const continueHere = useAssistantStore((s) => s.continueHere);
const send = useAssistantStore((s) => s.send);
const code = offline ? 'offline' : (error ?? connectionErrorOf(status));
if (!code) return null;
const effective = status?.effective;
const provider = effective ? providerLabel(effective.kind, effective.host) : '';
const view = errorView(code, { canConnectOwn: status?.canConnectOwn ?? false, provider });
const run = (kind: AssistantErrorActionKind) => {
if (kind === 'retry') void retry(tripId);
else if (kind === 'continueHere') void continueHere(tripId);
// The model asked whether to go on; the answer is the button's own word.
else if (kind === 'continue') void send(tripId, t('assistant.errorAction.continue'));
else if (kind === 'openSettings') void navigate(AI_SETTINGS_PATH);
else window.open(CHATGPT_USAGE_URL, '_blank', 'noopener,noreferrer');
};
const { action } = view;
const Icon = code === 'offline' ? WifiOff : CircleAlert;
return (
<div
role="status"
className="trek-assistant-enter flex items-center gap-2.5 rounded-lg bg-surface-secondary px-3 py-2 text-caption text-content-secondary"
>
<Icon size={14} aria-hidden="true" className="flex-shrink-0 text-warning" />
<span className="min-w-0 flex-1">{t(view.messageKey, { provider })}</span>
{action && (
<button
type="button"
onClick={() => run(action.kind)}
className="flex-shrink-0 rounded-md px-2 py-1 text-caption font-medium text-accent-on hover:bg-surface-hover"
>
{t(action.labelKey)}
</button>
)}
</div>
);
}
@@ -0,0 +1,74 @@
import { CircleCheck, TriangleAlert } from 'lucide-react';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../i18n';
import { useTripStore } from '../../store/tripStore';
import { Tooltip } from '../shared/Tooltip';
import { useAssistantView } from './assistantViewContext';
import { buildDayAfter, changedDays, type DayChange, type TimelineStop } from './dayAfterModel';
/** Lines the tooltip names; the rest are only counted on the pill. */
const MAX_LINES = 3;
/** What does not fit on a stop of the day: arriving after the next thing starts, or a way too tight. */
function problemOf(stop: TimelineStop, t: (key: string, params?: Record<string, string | number>) => string) {
const lead = stop.lead;
if (lead?.overlapsWith) return t('assistant.change.timeline.overlap', { place: lead.overlapsWith });
if (lead?.shortBy != null) return `${stop.name}: ${t('assistant.change.timeline.tight', { count: lead.shortBy })}`;
return null;
}
/**
* Whether the days a waiting card changes still work out in time, as one
* small pill in the card's head: "fits" in green, or how many things to look
* at in amber, with what they are on hover. The days themselves show the
* suggestion in the planner's day list, so the card does not draw them again.
* Shown only while the planner holds this trip, whose day plans it reads.
*/
export default function AssistantFitCheck({ changes }: { changes: readonly DayChange[] }) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const plan = useTripStore(
useShallow((s) => ({ here: s.trip?.id === tripId, days: s.days, assignments: s.assignments, places: s.places }))
);
const check = useMemo(() => {
if (!plan.here || changes.length === 0) return null;
const stops = changedDays(changes).flatMap((dayId) => {
const day = plan.days.find((d) => d.id === dayId);
if (!day) return [];
return buildDayAfter({
dayId,
assignments: plan.assignments,
places: plan.places,
changes,
defaultMode: day.default_transport_mode,
}).stops;
});
const measured = stops.some((stop) => stop.lead?.travelMinutes != null);
const problems = stops.map((stop) => problemOf(stop, t)).filter((line): line is string => line !== null);
return measured || problems.length > 0 ? problems : null;
}, [plan, changes, t]);
if (!check) return null;
const ok = check.length === 0;
const pill = (
<span
className={`inline-flex h-6 flex-shrink-0 items-center gap-1 rounded-full px-2 text-caption font-medium ${
ok ? 'bg-success-soft text-success' : 'bg-warning-soft text-warning'
}`}
>
{ok ? <CircleCheck size={12} aria-hidden="true" /> : <TriangleAlert size={12} aria-hidden="true" />}
{ok ? t('assistant.check.ok') : t('assistant.check.issues', { count: check.length })}
</span>
);
if (ok) return pill;
return (
<Tooltip label={check.slice(0, MAX_LINES).join('\n')} placement="top">
<span tabIndex={0} className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
{pill}
</span>
</Tooltip>
);
}
@@ -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>
);
}
@@ -0,0 +1,219 @@
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 { 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;
/** Moves the window: spread on the title, which is the part of the header to grab. */
dragProps: HTMLAttributes<HTMLElement>;
}
/**
* The window's head, in the look of TREK's dialog heads: a tinted band with
* a small line naming the assistant over the conversation's name; the band is where the window is
* grabbed to move it. On the end side the recent chats (a new chat starts at
* the top of that list), more and close, as round raised buttons. Closing
* 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({ 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);
const threads = thread.threads ?? [];
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
const { open: historyOpen, triggerRef: historyTrigger, listId: historyListId } = history;
const hints = useAssistantHintsSetting();
const openHistory = () => {
if (!history.open) thread.refreshThreads();
history.toggle();
};
const pick = (run: () => void) => {
history.close();
run();
};
const openMenu = (e: MouseEvent) =>
menu.open(
e,
[
{
label: t('assistant.history.new'),
icon: Plus,
disabled: thread.threadId === null && !thread.isRunning,
onClick: thread.newThread,
},
{
label: t(hints.on ? 'assistant.hint.menuOff' : 'assistant.hint.menuOn'),
icon: hints.on ? LightbulbOff : Lightbulb,
onClick: hints.toggle,
},
{ label: t('assistant.window.settings'), icon: Settings, onClick: () => void navigate(AI_SETTINGS_PATH) },
{
label: t('assistant.window.deleteThread'),
icon: Trash2,
danger: true,
disabled: thread.threadId === null || thread.isRunning,
onClick: () => setConfirming(true),
},
],
true
);
return (
<header
className="flex flex-shrink-0 items-center gap-2 border-b border-edge-faint py-2.5 pe-2.5 ps-4"
style={{ background: NEUTRAL_TINT }}
>
<div
{...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">
{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>
</div>
</div>
{/* Buttons are no place to start a drag from. */}
<div
className="flex flex-shrink-0 items-center gap-1"
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={historyTrigger}
type="button"
onClick={openHistory}
aria-label={t('assistant.history.open')}
aria-expanded={historyOpen}
aria-controls={historyOpen ? historyListId : undefined}
className={ICON_BUTTON}
>
<History size={13} aria-hidden="true" />
</button>
</Tooltip>
<Tooltip label={t('assistant.window.menu')}>
<button
type="button"
onClick={openMenu}
aria-label={t('assistant.window.menu')}
aria-haspopup="menu"
className={ICON_BUTTON}
>
<Ellipsis size={14} aria-hidden="true" />
</button>
</Tooltip>
<Tooltip label={t('common.close')}>
<button type="button" onClick={onClose} aria-label={t('common.close')} className={ICON_BUTTON}>
<X size={14} aria-hidden="true" />
</button>
</Tooltip>
</div>
<PopoverListPanel list={history} label={t('assistant.history.open')} className="w-72">
<PopoverItem
icon={<Plus size={14} />}
label={t('assistant.history.new')}
onClick={() => pick(thread.newThread)}
/>
{threads.length === 0 ? (
<p className="m-0 px-2.5 py-2 text-caption text-content-muted">{t('assistant.history.empty')}</p>
) : (
threads.map((th) => (
<PopoverItem
key={th.id}
icon={null}
label={th.title}
active={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))}
trailing={
<span className="flex-shrink-0 text-caption text-content-faint">
{new Date(th.lastMessageAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' })}
</span>
}
/>
))
)}
{thread.retentionNote && (
<p className="m-0 border-t border-edge-faint px-2.5 pb-1 pt-2 text-caption text-content-faint">
{thread.retentionNote}
</p>
)}
</PopoverListPanel>
<ContextMenu menu={menu.menu} onClose={menu.close} />
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<ConfirmDialog
isOpen={confirming}
onClose={() => setConfirming(false)}
onConfirm={() => {
setConfirming(false);
void thread.removeThread();
}}
title={t('assistant.window.deleteThread')}
message={t('assistant.window.deleteConfirm')}
confirmLabel={t('common.delete')}
/>
</header>
);
}
@@ -0,0 +1,88 @@
/**
* FE-AI-HINTLIST-001 to FE-AI-HINTLIST-006
*
* The hint list in the empty chat: three rows at most with the rest behind
* "n more", a row that asks, an X that waves one away, rows shown counted as
* seen, and rows that wait while offline.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import AssistantHints from './AssistantHints';
import type { AssistantHintsView, ReadyHint } from './useAssistantHints';
const network = vi.hoisted(() => ({ offline: false }));
vi.mock('../../hooks/useNetworkMode', () => ({
useNetworkMode: () => ({ offline: network.offline, forced: false, setForced: () => undefined }),
}));
function hint(id: string, level: ReadyHint['level'] = 'info'): ReadyHint {
const rule = level === 'warn' ? 'overlap' : 'gap';
return { id, rule, level, text: `Line ${id}`, prompt: `Ask ${id}`, focus: null };
}
function view(hints: ReadyHint[]): AssistantHintsView {
return {
hints,
unseenWarn: 0,
markSeen: vi.fn(),
markWarnsSeen: vi.fn(),
dismiss: vi.fn(),
ask: vi.fn(),
};
}
beforeEach(() => {
network.offline = false;
});
describe('AssistantHints', () => {
it('FE-AI-HINTLIST-001: shows nothing without hints, and three rows with the rest behind "n more"', () => {
const { container, unmount } = render(<AssistantHints view={view([])} phone={false} />);
expect(container).toBeEmptyDOMElement();
unmount();
const five = view(['a', 'b', 'c', 'd', 'e'].map((id) => hint(id)));
render(<AssistantHints view={five} phone={false} />);
expect(screen.getByRole('region', { name: 'Worth a look' })).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(3);
expect(five.markSeen).toHaveBeenLastCalledWith(['a', 'b', 'c']);
fireEvent.click(screen.getByRole('button', { name: '2 more' }));
expect(screen.getAllByRole('listitem')).toHaveLength(5);
expect(five.markSeen).toHaveBeenLastCalledWith(['a', 'b', 'c', 'd', 'e']);
});
it('FE-AI-HINTLIST-002: a row asks its question, and the X waves the hint away', () => {
const one = view([hint('a', 'warn')]);
render(<AssistantHints view={one} phone={false} />);
fireEvent.click(screen.getByRole('button', { name: 'Line a' }));
expect(one.ask).toHaveBeenCalledWith(one.hints[0]);
fireEvent.click(screen.getByRole('button', { name: 'Hide this hint' }));
expect(one.dismiss).toHaveBeenCalledWith('a');
});
it('FE-AI-HINTLIST-003: on the phone the X is always there, with room for a thumb', () => {
render(<AssistantHints view={view([hint('a')])} phone />);
const dismiss = screen.getByRole('button', { name: 'Hide this hint' });
expect(dismiss.className).toContain('h-10');
expect(dismiss.className).not.toContain('opacity-0');
});
it('FE-AI-HINTLIST-006: in the window the X hides until hover only on a screen that can hover', () => {
render(<AssistantHints view={view([hint('a')])} phone={false} />);
const dismiss = screen.getByRole('button', { name: 'Hide this hint' });
expect(dismiss.className).toContain('[@media(hover:hover)]:opacity-0');
expect(dismiss.className.split(' ')).not.toContain('opacity-0');
});
it('FE-AI-HINTLIST-004: offline the rows stay readable but do not ask', () => {
network.offline = true;
const one = view([hint('a')]);
render(<AssistantHints view={one} phone={false} />);
const row = screen.getByRole('button', { name: 'Line a' });
expect(row).toBeDisabled();
fireEvent.click(row);
expect(one.ask).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,114 @@
import { BedDouble, Clock, type LucideIcon, MapPin, Ticket, TriangleAlert, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { useTranslation } from '../../i18n';
import Tooltip from '../shared/Tooltip';
import type { HintRule } from './assistantHintRules';
import type { AssistantHintsView, ReadyHint } from './useAssistantHints';
/** Rows shown before "n more", so the suggestions under them stay in view. */
export const MAX_VISIBLE_HINTS = 3;
/** Warnings all carry the one triangle; a note says what it is about. */
const NOTE_ICONS: Partial<Record<HintRule, LucideIcon>> = {
gap: Clock,
longLeg: MapPin,
noStay: BedDouble,
unplacedBooking: Ticket,
};
/**
* The plan hints in the empty chat: a short, quiet list, at most three rows
* with the rest behind "n more". A row is the button: it puts its day or
* places in focus and asks its question. Each can be waved away. On the
* desktop the X shows on hover or focus, on the phone always, with room for
* a thumb. Shown means seen, so the launcher stops pointing at them.
*/
export default function AssistantHints({ view, phone }: { view: AssistantHintsView; phone: boolean }) {
const { t } = useTranslation();
const { offline } = useNetworkMode();
const [expanded, setExpanded] = useState(false);
const shown = expanded ? view.hints : view.hints.slice(0, MAX_VISIBLE_HINTS);
const rest = view.hints.length - shown.length;
const shownIds = shown.map((h) => h.id).join('\n');
const { markSeen } = view;
useEffect(() => {
if (shownIds) markSeen(shownIds.split('\n'));
}, [shownIds, markSeen]);
if (view.hints.length === 0) return null;
return (
<section aria-label={t('assistant.hint.title')} className="flex w-full max-w-sm flex-col gap-1.5">
<h3 className="m-0 px-1 text-caption font-medium text-content-muted">{t('assistant.hint.title')}</h3>
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
{shown.map((hint) => (
<HintRow key={hint.id} hint={hint} phone={phone} disabled={offline} view={view} />
))}
</ul>
{rest > 0 && (
<button
type="button"
onClick={() => setExpanded(true)}
className={`self-start rounded-lg px-1 text-caption text-content-muted hover:text-content ${phone ? 'min-h-[44px]' : 'py-1'}`}
>
{t('assistant.hint.more', { count: rest })}
</button>
)}
</section>
);
}
function HintRow({
hint,
phone,
disabled,
view,
}: {
hint: ReadyHint;
phone: boolean;
disabled: boolean;
view: AssistantHintsView;
}) {
const { t } = useTranslation();
const warn = hint.level === 'warn';
const Icon = warn ? TriangleAlert : (NOTE_ICONS[hint.rule] ?? Clock);
const dismissLabel = t('assistant.hint.dismiss');
return (
<li className="group relative">
<button
type="button"
disabled={disabled}
onClick={() => view.ask(hint)}
className={`flex w-full items-start gap-2 rounded-xl border border-edge-faint bg-surface-card py-2 ps-3 text-start text-body text-content transition-colors hover:border-edge hover:bg-surface-hover disabled:cursor-default disabled:opacity-60 disabled:hover:border-edge-faint disabled:hover:bg-surface-card ${
phone ? 'min-h-[44px] pe-11' : 'pe-9'
}`}
>
<Icon
size={14}
aria-hidden="true"
className={`mt-[3px] flex-shrink-0 ${warn ? 'text-content-secondary' : 'text-content-muted'}`}
/>
<span dir="auto" className="line-clamp-2 min-w-0 flex-1">
{hint.text}
</span>
</button>
{/* Hidden until hover only where there is hover: a touch screen at window size shows it always. */}
<Tooltip label={dismissLabel}>
<button
type="button"
onClick={() => view.dismiss(hint.id)}
aria-label={dismissLabel}
className={`absolute grid place-items-center rounded-lg text-content-faint transition-opacity hover:bg-surface-hover hover:text-content ${
phone
? 'end-0.5 top-0.5 h-10 w-10'
: 'end-1 top-1 h-7 w-7 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:hover)]:opacity-0'
}`}
>
<X size={13} aria-hidden="true" />
</button>
</Tooltip>
</li>
);
}
@@ -0,0 +1,93 @@
/**
* FE-AI-HOST-001 to FE-AI-HOST-005
*
* The desktop host follows the trip on screen and sends "Show on map" and a
* picked day to the planner through its links.
*/
import type { AssistantApplied } from '@trek/shared';
import { useLocation } from 'react-router';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen } from '../../../tests/helpers/render';
import { useTripStore } from '../../store/tripStore';
import type { Trip } from '../../types';
import AssistantHost from './AssistantHost';
interface WindowProps {
trip: { id: number; title: string } | null;
onShowOnMap?: (applied: AssistantApplied) => void;
onFocusDay?: (dayId: number) => void;
}
const seen: { props: WindowProps | null } = { props: null };
vi.mock('./AssistantWindow', () => ({
default: (props: WindowProps) => {
seen.props = props;
return null;
},
}));
function Location() {
const { pathname, search } = useLocation();
return <output data-testid="location">{`${pathname}${search}`}</output>;
}
function renderAt(path: string) {
return render(
<>
<AssistantHost />
<Location />
</>,
{ initialEntries: [path] }
);
}
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
toolCallId: 'c1',
entity,
ids,
dayId,
undoUntil: null,
});
const INITIAL = useTripStore.getState();
afterEach(() => {
useTripStore.setState(INITIAL, true);
seen.props = null;
});
describe('AssistantHost', () => {
it('FE-AI-HOST-001: outside a trip the window gets no trip and asks which one', () => {
renderAt('/dashboard');
expect(seen.props?.trip).toBeNull();
});
it('FE-AI-HOST-002: inside a trip it is about that trip, named once the planner has it', () => {
renderAt('/trips/5/files');
expect(seen.props?.trip).toEqual({ id: 5, title: '' });
act(() => useTripStore.setState({ trip: { id: 5, title: 'Kyoto' } as Trip }));
expect(seen.props?.trip).toEqual({ id: 5, title: 'Kyoto' });
});
it('FE-AI-HOST-003: "Show on map" opens the planner on the place, or on the day', () => {
renderAt('/trips/5/files');
act(() => seen.props?.onShowOnMap?.(applied('place', [7], 2)));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?place=7');
act(() => seen.props?.onShowOnMap?.(applied('day_note', [9], 3)));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=3');
});
it('FE-AI-HOST-005: a day picked in the chat selects it in the planner', () => {
renderAt('/trips/5');
act(() => seen.props?.onFocusDay?.(12));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=12');
});
it('FE-AI-HOST-004: a route that only looks like a trip is none', () => {
renderAt('/trips/new');
expect(seen.props?.trip).toBeNull();
});
});
@@ -0,0 +1,43 @@
import type { AssistantApplied } from '@trek/shared';
import { useCallback, useMemo } from 'react';
import { useLocation, useMatch, useNavigate } from 'react-router';
import { useTripStore } from '../../store/tripStore';
import AssistantWindow from './AssistantWindow';
import { showOnMapSearch } from './assistantShowOnMap';
/** The trip a route under /trips/:id shows, or null anywhere else. */
function useRouteTripId(): number | null {
const match = useMatch('/trips/:id/*');
const id = Number(match?.params.id);
return Number.isInteger(id) && id > 0 ? id : null;
}
/**
* The desktop assistant, mounted once above the routes so a conversation
* keeps going from page to page. Inside a trip it is about that trip; "Show
* on map" opens the planner on the change through its links (`?place=`,
* `?day=`), the same way a notification opens a place, and a day picked in
* the chat (its weather) selects that day the same way.
*/
export default function AssistantHost() {
const navigate = useNavigate();
const { pathname } = useLocation();
const tripId = useRouteTripId();
// The title is there once the planner has loaded the trip; the window waits for nothing else.
const title = useTripStore((s) => (s.trip && s.trip.id === tripId ? s.trip.title : ''));
const trip = useMemo(() => (tripId === null ? null : { id: tripId, title }), [tripId, title]);
const openPlanner = useCallback(
(search: string) => {
if (tripId === null) return;
const planner = `/trips/${tripId}`;
void navigate({ pathname: planner, search }, { replace: pathname === planner });
},
[navigate, pathname, tripId]
);
const showOnMap = useCallback((applied: AssistantApplied) => openPlanner(showOnMapSearch(applied)), [openPlanner]);
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
return <AssistantWindow trip={trip} onShowOnMap={showOnMap} onFocusDay={focusDay} />;
}
@@ -0,0 +1,128 @@
/**
* FE-AI-LAUNCH-001 to FE-AI-LAUNCH-006
*
* The way in: hidden until the assistant can run, a button that opens and
* closes the window, a dot for warnings in the plan not seen yet, and
* ⌘K / Ctrl+K, which leaves a dialog alone.
*/
import { renderHook } from '@testing-library/react';
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import AssistantLauncher from './AssistantLauncher';
import { isAssistantHotkey, useAssistantHotkey } from './useAssistantHotkey';
const INITIAL = useAssistantStore.getState();
const STATUS = { available: true } as AiStatusResponse;
function press(init: KeyboardEventInit): KeyboardEvent {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
window.dispatchEvent(event);
return event;
}
afterEach(() => useAssistantStore.setState(INITIAL, true));
describe('AssistantLauncher', () => {
it('FE-AI-LAUNCH-001: shows nothing until the assistant has a connection to use', () => {
useAssistantStore.setState({ status: { ...STATUS, available: false } });
const { container } = render(<AssistantLauncher variant="navbar" />);
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-LAUNCH-002: the navbar button and the floating one open and close the window', () => {
useAssistantStore.setState({ status: STATUS });
const { unmount } = render(<AssistantLauncher variant="navbar" />);
const button = screen.getByRole('button', { name: 'Assistant' });
fireEvent.click(button);
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
expect(button).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(button);
expect(useAssistantStore.getState().window.open).toBe(false);
unmount();
render(<AssistantLauncher variant="floating" />);
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
expect(useAssistantStore.getState().window.open).toBe(true);
});
it('FE-AI-LAUNCH-006: the floating button carries a dot for a warning not seen yet, until the chat is opened', () => {
window.localStorage.clear();
resetAllStores();
const places = [
buildPlace({ id: 50, place_time: '10:00', end_time: '12:00' }),
buildPlace({ id: 51, place_time: '11:00', end_time: '12:30' }),
];
const stops = places.map((place, i) =>
buildAssignment({ id: 60 + i, day_id: 1, place_id: place.id, order_index: i, place })
);
useTripStore.setState({
trip: buildTrip({ id: 3, start_date: '2099-06-01', end_date: '2099-06-01' }),
days: [buildDay({ id: 1, trip_id: 3, date: '2099-06-01' })],
places,
assignments: { '1': stops },
reservations: [],
dayNotes: {},
});
useAssistantStore.setState({ status: STATUS });
const { unmount } = render(<AssistantLauncher variant="navbar" />);
expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument();
unmount();
render(<AssistantLauncher variant="floating" tripId={3} />);
const button = screen.getByRole('button', { name: 'Assistant, 1 hint' });
// The dot stands out from the accent button it sits on.
expect(button.querySelector('.bg-warning')).not.toBeNull();
fireEvent.click(button);
expect(useAssistantStore.getState().window.open).toBe(true);
expect(screen.getByRole('button', { name: 'Assistant' }).querySelector('.bg-warning')).toBeNull();
});
});
describe('the shortcut', () => {
it('FE-AI-LAUNCH-003: is ⌘K or Ctrl+K, without Alt or Shift', () => {
const key = (init: KeyboardEventInit) => isAssistantHotkey(new KeyboardEvent('keydown', init));
expect(key({ key: 'k', metaKey: true })).toBe(true);
expect(key({ key: 'K', ctrlKey: true })).toBe(true);
expect(key({ key: 'k' })).toBe(false);
expect(key({ key: 'k', ctrlKey: true, altKey: true })).toBe(false);
expect(key({ key: 'k', metaKey: true, shiftKey: true })).toBe(false);
expect(key({ key: 'j', metaKey: true })).toBe(false);
});
it('FE-AI-LAUNCH-004: toggles and takes the key, only while enabled', () => {
const onToggle = vi.fn();
const { rerender } = renderHook(({ enabled }) => useAssistantHotkey(enabled, onToggle), {
initialProps: { enabled: false },
});
expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(false);
expect(onToggle).not.toHaveBeenCalled();
rerender({ enabled: true });
expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(true);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it('FE-AI-LAUNCH-005: leaves the key alone while a dialog is open or something else took it', () => {
const onToggle = vi.fn();
renderHook(() => useAssistantHotkey(true, onToggle));
const backdrop = document.createElement('div');
backdrop.className = 'trek-modal-backdrop';
document.body.appendChild(backdrop);
press({ key: 'k', metaKey: true });
backdrop.remove();
const taken = new KeyboardEvent('keydown', { key: 'k', metaKey: true, cancelable: true });
taken.preventDefault();
window.dispatchEvent(taken);
expect(onToggle).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,106 @@
import { Sparkles } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { useAssistantStore } from '../../store/assistantStore';
import Tooltip from '../shared/Tooltip';
import { useAssistantHints } from './useAssistantHints';
import { useAssistantShortcutKeys } from './useAssistantHotkey';
/** Where the floating button sits, from the window's end and bottom edges. The window grows out of it. */
export const LAUNCHER_INSET = 24;
export const LAUNCHER_SIZE = 48;
/**
* The way into the assistant: a button in the navbar, and in the planner a
* round one floating at the bottom end of the screen. Both open and close the
* window; outside a trip it opens on the trip picker. Nothing shows until the
* assistant has a connection this user can use: no setup prompt in its place.
* The floating one carries a dot while the trip has a warning the user has
* not been shown; opening the chat counts them as seen.
*/
export default function AssistantLauncher({
variant,
tripId = null,
focusOnMount = false,
}: {
variant: 'navbar' | 'floating';
/** The trip the floating button stands in, for its hint dot. */
tripId?: number | null;
/** Takes the focus when it appears, as when the window it stands for was just closed. */
focusOnMount?: boolean;
}) {
const { t } = useTranslation();
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open && !s.window.minimized);
const toggle = useAssistantStore((s) => s.toggle);
// An answer still being written in a closed window: the floating button breathes until it is opened.
const busy = useAssistantStore((s) =>
!s.window.open || s.window.minimized ? Object.values(s.byTrip).some((c) => c?.isRunning) : false
);
const keys = useAssistantShortcutKeys();
const hints = useAssistantHints(variant === 'floating' ? tripId : null, { withStays: false });
const floating = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (focusOnMount) floating.current?.focus();
}, [focusOnMount]);
if (!available) return null;
const label = t('assistant.launcher.tooltip');
if (variant === 'navbar') {
return (
<Tooltip label={label} shortcut={keys}>
<button
type="button"
onClick={toggle}
aria-label={label}
aria-pressed={open}
className={`relative hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg p-2 transition-colors sm:flex ${
open ? 'bg-surface-selected text-content' : 'text-content-muted hover:bg-surface-hover'
}`}
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</button>
</Tooltip>
);
}
const pointed = hints.unseenWarn > 0 ? t('assistant.launcher.withHints', { count: hints.unseenWarn }) : label;
const openChat = () => {
if (!open) hints.markWarnsSeen();
toggle();
};
return (
<Tooltip label={pointed} shortcut={keys} placement="top">
<button
ref={floating}
type="button"
onClick={openChat}
aria-label={pointed}
aria-busy={busy || undefined}
className="trek-assistant-pop fixed z-[var(--z-nav)] flex items-center justify-center rounded-full bg-accent text-accent-text shadow-elevated transition-transform hover:scale-105 active:scale-95"
style={{
insetInlineEnd: LAUNCHER_INSET,
bottom: LAUNCHER_INSET,
width: LAUNCHER_SIZE,
height: LAUNCHER_SIZE,
transformOrigin: 'center',
}}
>
{busy && (
<span
aria-hidden="true"
className="trek-assistant-busy absolute inset-0 rounded-full border-2 border-current opacity-40"
/>
)}
<Sparkles size={20} aria-hidden="true" />
{hints.unseenWarn > 0 && (
<span
aria-hidden="true"
className="absolute end-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-warning ring-2 ring-[var(--bg-elevated)]"
/>
)}
</button>
</Tooltip>
);
}
@@ -0,0 +1,81 @@
/**
* FE-AI-LIVE-001 to FE-AI-LIVE-004
*
* The running step's line: what it does, and once the stream says where it
* looks, the place or the query, with a pin when the map can show the area.
*/
import type { AssistantActivity, AssistantMessage } from '@trek/shared';
import { afterEach, describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import AssistantToolSteps from './AssistantToolSteps';
import { AssistantViewContext } from './assistantViewContext';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const running: AssistantMessage = {
id: 'msg-2',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-21T08:00:00.000Z',
parts: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'search_nearby_places', args: {} }],
};
function seed(activity: AssistantActivity | null) {
useAssistantStore.setState({
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages: [running], live: { activity } } },
});
}
function renderSteps() {
return render(
<AssistantViewContext.Provider value={{ tripId: TRIP, variant: 'window' }}>
<AssistantToolSteps steps={[{ toolCallId: 'call-1', toolName: 'search_nearby_places', running: true }]} />
</AssistantViewContext.Provider>
);
}
afterEach(() => useAssistantStore.setState(INITIAL, true));
describe('AssistantLiveStep', () => {
it('FE-AI-LIVE-001: names the place the running step searches near', () => {
seed({
toolCallId: 'call-1',
label: 'Trastevere',
area: { type: 'point', lat: 41.88, lng: 12.47, radiusM: 800 },
});
const { container } = renderSteps();
expect(screen.getByRole('status')).toHaveTextContent('Searching near Trastevere');
expect(container.querySelector('svg')).not.toBeNull();
});
it('FE-AI-LIVE-002: without a place it keeps the step name, and no pin', () => {
seed({ toolCallId: 'call-1', label: null, area: null });
const { container } = renderSteps();
expect(screen.getByRole('status')).toHaveTextContent('Searching nearby');
expect(container.querySelector('svg')).toBeNull();
});
it("FE-AI-LIVE-003: a place another step named is not this step's", () => {
seed({ toolCallId: 'call-0', label: 'Trastevere', area: null });
renderSteps();
expect(screen.getByRole('status')).toHaveTextContent('Searching nearby');
});
it('FE-AI-LIVE-004: with "map follows the assistant" off the place stays, the pin goes', () => {
const settings = useSettingsStore.getState().settings;
useSettingsStore.setState({ settings: { ...settings, assistant_map_follow: false } });
seed({
toolCallId: 'call-1',
label: 'Trastevere',
area: { type: 'point', lat: 41.88, lng: 12.47, radiusM: 800 },
});
const { container } = renderSteps();
expect(screen.getByRole('status')).toHaveTextContent('Searching near Trastevere');
expect(container.querySelector('svg')).toBeNull();
useSettingsStore.setState({ settings });
});
});
@@ -0,0 +1,24 @@
import { MapPin } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { toolStepKey } from './assistantModel';
import { useAssistantView } from './assistantViewContext';
import { useAssistantLiveStep } from './useAssistantLiveStep';
/**
* The running step as one quiet line: what it does, shimmering, and once it
* names a place, where ("Searching near Trastevere"). A small pin says the
* map is looking there too, shown only while the map follows the assistant.
*/
export default function AssistantLiveStep({ toolCallId, toolName }: { toolCallId: string; toolName: string }) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const live = useAssistantLiveStep(tripId);
const here = live?.toolCallId === toolCallId ? live : null;
return (
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption">
{here?.hasArea && <MapPin size={12} aria-hidden="true" className="flex-shrink-0 text-content-faint" />}
<span className="trek-assistant-shimmer min-w-0 truncate">{here ? here.text : t(toolStepKey(toolName))}</span>
</p>
);
}
@@ -0,0 +1,48 @@
/**
* FE-AI-MD-001 to FE-AI-MD-005
*
* An answer is untrusted text: nothing in it may load by itself, raw HTML
* never becomes markup, and a link always shows where it goes.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import AssistantMarkdown from './AssistantMarkdown';
describe('AssistantMarkdown', () => {
it('FE-AI-MD-001: renders Markdown as text, lists and emphasis', () => {
const { container } = render(<AssistantMarkdown text={'**Kyoto** first\n\n- Kinkaku-ji\n- Ryoan-ji'} />);
expect(container.querySelector('strong')).toHaveTextContent('Kyoto');
expect(screen.getAllByRole('listitem')).toHaveLength(2);
});
it('FE-AI-MD-002: draws no image, in Markdown or as raw HTML', () => {
const { container } = render(
<AssistantMarkdown text={'![pixel](https://evil.example/p.gif)\n\n<img src="https://evil.example/q.gif">'} />
);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('picture, video, audio, iframe')).toBeNull();
});
it('FE-AI-MD-003: leaves no javascript: link to click', () => {
const { container } = render(<AssistantMarkdown text={'[open](javascript:alert(1))'} />);
const link = container.querySelector('a');
expect(link?.getAttribute('href') ?? '').not.toMatch(/^javascript:/i);
});
it('FE-AI-MD-004: an outside link opens in its own tab and names its host', () => {
render(<AssistantMarkdown text={'[Tickets](https://www.kinkaku-ji.or.jp/en/)'} />);
const link = screen.getByRole('link', { name: /Tickets/ });
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
expect(link).toHaveTextContent('(www.kinkaku-ji.or.jp)');
});
it('FE-AI-MD-005: a link into TREK stays in the app, and the writing caret is marked', () => {
const { container } = render(<AssistantMarkdown text={'See [day 2](/trips/4)'} writing />);
const link = screen.getByRole('link', { name: 'day 2' });
expect(link).toHaveAttribute('href', '/trips/4');
expect(link).not.toHaveAttribute('target');
expect(container.firstElementChild).toHaveAttribute('data-writing', 'true');
});
});
@@ -0,0 +1,64 @@
import { memo } from 'react';
import Markdown, { type Components } from 'react-markdown';
import { Link } from 'react-router';
import remarkGfm from 'remark-gfm';
import { sanitizedMarkdownPlugins } from '../shared/markdownSanitize';
/**
* An answer's text, as Markdown. The model writes it, and what the model reads
* (trip notes, place descriptions, search results) can carry someone else's
* words, so it renders like untrusted input: raw HTML is skipped and
* sanitized, and nothing that loads on its own (an image is a tracking pixel
* that leaks a request to any host) is drawn at all.
*
* A link shows where it goes: an outside one names its host beside the text
* and opens in its own tab, a link into TREK stays in the app.
*/
const DISALLOWED = ['img', 'picture', 'source', 'video', 'audio', 'iframe', 'object', 'embed'];
function hostOf(href: string): string | null {
try {
return new URL(href).host || null;
} catch {
return null;
}
}
const components: Components = {
a: ({ href, children }) => {
if (href?.startsWith('/') && !href.startsWith('//')) return <Link to={href}>{children}</Link>;
const host = href ? hostOf(href) : null;
const text = typeof children === 'string' ? children : null;
return (
<a href={href} target="_blank" rel="noopener noreferrer nofollow">
{children}
{host && text !== host && text !== href && <span className="text-content-muted"> ({host})</span>}
</a>
);
},
};
function AssistantMarkdown({ text, writing = false }: { text: string; writing?: boolean }) {
return (
<div
dir="auto"
data-writing={writing || undefined}
className="trek-assistant-md break-words text-body text-content"
>
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={sanitizedMarkdownPlugins}
disallowedElements={DISALLOWED}
skipHtml
components={components}
>
{text}
</Markdown>
</div>
);
}
// An answer re-renders with every streamed word; a block whose text did not change keeps its parse.
export default memo(AssistantMarkdown);
@@ -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>
);
}
@@ -0,0 +1,133 @@
import {
ComposerPrimitive,
type TriggerAdapter,
type TriggerItem,
unstable_useTriggerPopoverScopeContext as useTriggerScope,
} from '@assistant-ui/react';
import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Ticket } from 'lucide-react';
import { type MouseEvent, useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { MENTION_FORMATTER, MENTION_KINDS, type MentionKind } from './assistantMentionModel';
import { useAssistantView } from './assistantViewContext';
/** The icon of a day, place or booking in the `@` list. */
function MentionKindIcon({ kind, size }: { kind: string; size: number }) {
const props = { size, 'aria-hidden': true, className: 'flex-shrink-0' } as const;
if (kind === 'day') return <CalendarDays {...props} />;
if (kind === 'reservation') return <Ticket {...props} />;
return <MapPin {...props} />;
}
/** A row takes the click without the focus, so the caret stays in the text and typing goes on. */
const keepFocus = (e: MouseEvent) => e.preventDefault();
/**
* The list `@` opens over the composer: first the kinds (days, places,
* bookings), and once letters follow the `@`, the entries that match across
* all of them. The focus never leaves the text; arrows walk the list, Enter
* or Tab picks, Escape closes it and keeps what was typed.
*/
export default function AssistantMentionPopover({
adapter,
onInserted,
}: {
/** Undefined while the trip's lists are not at hand; `@` then opens nothing. */
adapter: TriggerAdapter | undefined;
onInserted: (item: TriggerItem) => void;
}) {
const { t } = useTranslation();
const { variant } = useAssistantView();
const phone = variant === 'fullscreen';
return (
<ComposerPrimitive.TriggerPopover
char="@"
adapter={adapter}
aria-label={t('assistant.mention.hint')}
className={`absolute bottom-full end-0 start-0 z-30 mb-1.5 overflow-y-auto rounded-xl border border-edge-faint bg-surface-card p-1 shadow-dropdown ${phone ? 'max-h-[40vh]' : 'max-h-56'}`}
>
<ComposerPrimitive.TriggerPopover.Directive formatter={MENTION_FORMATTER} onInserted={onInserted} />
<MentionList row={phone ? 'min-h-[44px]' : 'min-h-[32px]'} />
</ComposerPrimitive.TriggerPopover>
);
}
const ROW =
'flex w-full items-center gap-2 rounded-lg px-2 text-start text-body text-content transition-colors hover:bg-surface-hover data-[highlighted]:bg-surface-hover';
function MentionList({ row }: { row: string }) {
const { t } = useTranslation();
const scope = useTriggerScope();
const listRef = useRef<HTMLDivElement>(null);
// The highlighted row stays in view while the arrows walk past the list's edge.
useEffect(() => {
const hot = listRef.current?.querySelector<HTMLElement>('[data-highlighted]');
hot?.scrollIntoView?.({ block: 'nearest' });
}, [scope.highlightedIndex, scope.items, scope.activeCategoryId]);
if (!scope.open) return null;
const listing = scope.isSearchMode || scope.activeCategoryId !== null;
const active = scope.activeCategoryId ? MENTION_KINDS[scope.activeCategoryId as MentionKind] : undefined;
const heading = active ? t(active.labelKey) : null;
return (
<div ref={listRef}>
{heading && (
<ComposerPrimitive.TriggerPopoverBack
onMouseDown={keepFocus}
className="flex w-full items-center gap-1 rounded-lg px-1.5 py-1 text-caption font-semibold text-content-muted hover:text-content"
>
<ChevronLeft size={13} aria-hidden="true" className="rtl:-scale-x-100" />
{heading}
</ComposerPrimitive.TriggerPopoverBack>
)}
<ComposerPrimitive.TriggerPopoverCategories aria-label={t('assistant.mention.hint')}>
{(categories) =>
categories.map((c) => (
<ComposerPrimitive.TriggerPopoverCategoryItem
key={c.id}
categoryId={c.id}
onMouseDown={keepFocus}
className={`${ROW} ${row}`}
>
<span className="flex text-content-muted">
<MentionKindIcon kind={c.id} size={14} />
</span>
<span className="flex-1 truncate">{c.label}</span>
<ChevronRight
size={13}
aria-hidden="true"
className="flex-shrink-0 text-content-faint rtl:-scale-x-100"
/>
</ComposerPrimitive.TriggerPopoverCategoryItem>
))
}
</ComposerPrimitive.TriggerPopoverCategories>
<ComposerPrimitive.TriggerPopoverItems aria-label={heading ?? t('assistant.mention.hint')}>
{(items) =>
items.map((item, index) => (
<ComposerPrimitive.TriggerPopoverItem
key={item.id}
item={item}
index={index}
onMouseDown={keepFocus}
className={`${ROW} ${row}`}
>
<span className="flex text-content-muted">
<MentionKindIcon kind={item.type} size={14} />
</span>
<bdi className="min-w-0 flex-1 truncate">{item.label}</bdi>
{item.description && (
<span className="flex-shrink-0 text-caption text-content-faint">{item.description}</span>
)}
</ComposerPrimitive.TriggerPopoverItem>
))
}
</ComposerPrimitive.TriggerPopoverItems>
{listing && scope.items.length === 0 && (
<p className="m-0 px-2 py-1.5 text-caption text-content-muted">{t('assistant.mention.none')}</p>
)}
</div>
);
}
@@ -0,0 +1,42 @@
/**
* FE-AI-MENTION-TEXT-001 to FE-AI-MENTION-TEXT-002
*
* A sent question with the days, places and bookings it named set off as
* chips, and every other `@` left as text.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import AssistantMentionText from './AssistantMentionText';
describe('AssistantMentionText', () => {
it('FE-AI-MENTION-TEXT-001: sets off each named @label and keeps the words around it', () => {
const { container } = render(
<p>
<AssistantMentionText
text="Is @Louvre open on @Arrival?"
items={[
{ kind: 'place', id: 21, label: 'Louvre' },
{ kind: 'day', id: 11, label: 'Arrival' },
]}
/>
</p>
);
expect(container.querySelector('p')?.textContent).toBe('Is @Louvre open on @Arrival?');
expect(screen.getByText('@Louvre').tagName).toBe('BDI');
expect(screen.getByText('@Arrival').tagName).toBe('BDI');
});
it('FE-AI-MENTION-TEXT-002: an @ that names nothing stays plain text', () => {
const { container } = render(
<p>
<AssistantMentionText
text="Write to me @ home or @Louvre2"
items={[{ kind: 'place', id: 21, label: 'Louvre' }]}
/>
</p>
);
expect(container.querySelector('p')?.textContent).toBe('Write to me @ home or @Louvre2');
expect(container.querySelector('bdi')).toBeNull();
});
});
@@ -0,0 +1,33 @@
import type { AssistantMentions } from '@trek/shared';
import { useMemo } from 'react';
import { splitMentions } from './assistantMentionModel';
/**
* A sent question as the user wrote it, with every `@label` it named a day,
* place or booking with set off as a quiet inline chip. The labels come from
* the question's own mentions part, so a stray `@` in the text stays text.
*/
export default function AssistantMentionText({ text, items }: { text: string; items: AssistantMentions['items'] }) {
const segments = useMemo(
() =>
splitMentions(
text,
items.map((i) => i.label)
),
[text, items]
);
return (
<>
{segments.map((s) =>
s.mention ? (
<bdi key={s.at} className="rounded-md bg-surface-card px-1 font-medium text-content">
{s.text}
</bdi>
) : (
<span key={s.at}>{s.text}</span>
)
)}
</>
);
}
@@ -0,0 +1,162 @@
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
import type { AssistantMessage as StoredMessage } from '@trek/shared';
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
import { useMemo } from 'react';
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';
import AssistantShareDialog from './AssistantShareDialog';
import AssistantToolSteps from './AssistantToolSteps';
import AssistantWeatherStrip from './AssistantWeatherStrip';
import { messageBlocks } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { useAssistantShare } from './useAssistantShare';
/**
* One message of the chat. The user's question sits on the end side in a
* quiet accent bubble; the answer runs as plain text across the column, with
* its looked-up steps, the places and weather they found, and the proposed
* changes in between, and a copy and a share button that show on hover
* (always on the latest answer, and always on the phone, which has no hover).
*/
const BAR_BUTTON =
'rounded-md p-1 text-content-faint hover:bg-surface-hover hover:text-content-secondary disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent';
/** Posts the answer to the trip chat after a preview; a tick once it went. */
function ShareButton({ share }: { share: ReturnType<typeof useAssistantShare> }) {
const { t } = useTranslation();
if (share.shared) {
return (
<Tooltip label={t('assistant.share.shared')}>
<span className="inline-flex p-1 text-content-faint">
<Check size={14} aria-hidden="true" />
<span className="sr-only">{t('assistant.share.shared')}</span>
</span>
</Tooltip>
);
}
const label = share.offline ? t('assistant.error.offline') : t('assistant.share.button');
return (
<Tooltip label={label}>
{/* The wrapper carries the tooltip while the button is off. */}
<span className="inline-flex">
<button
type="button"
onClick={share.open}
disabled={share.offline || share.sending}
aria-label={t('assistant.share.button')}
className={BAR_BUTTON}
>
{share.sending ? (
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
) : (
<Share2 size={14} aria-hidden="true" />
)}
</button>
</span>
</Tooltip>
);
}
/** A file the user handed over, as a chip with what it is and how big. */
function AttachmentChip({ name, size }: { name: string; size: number }) {
const kb = Math.max(1, Math.round(size / 1024));
return (
<span className="inline-flex max-w-full items-center gap-2.5 rounded-xl border border-edge-faint bg-surface-card py-2 pe-3.5 ps-2 shadow-card">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-content-secondary">
<FileText size={16} aria-hidden="true" />
</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>
</span>
);
}
function Question({ message }: { message: StoredMessage }) {
const text = message.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
const files = message.parts.flatMap((p) => (p.type === 'attachment' ? [p] : []));
const mentions = message.parts.flatMap((p) => (p.type === 'data-trek-mentions' ? p.data.items : []));
return (
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col items-end gap-1.5 py-1.5">
{files.map((f, i) => (
<AttachmentChip key={`${f.name}-${i}`} name={f.name} size={f.size} />
))}
{text && (
<p
dir="auto"
className="m-0 max-w-[85%] whitespace-pre-wrap break-words rounded-xl bg-accent-subtle px-3.5 py-2 text-body text-content"
>
{mentions.length > 0 ? <AssistantMentionText text={text} items={mentions} /> : text}
</p>
)}
</MessagePrimitive.Root>
);
}
function Answer({ message }: { message: StoredMessage }) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
const writing = useAssistantStore((s) => {
const chat = chatOf(s, tripId);
return chat.isRunning && chat.messages[chat.messages.length - 1]?.id === message.id;
});
const blocks = useMemo(() => messageBlocks(message, writing), [message, writing]);
const share = useAssistantShare(tripId, message, writing);
const last = blocks[blocks.length - 1];
const hasText = blocks.some((b) => b.kind === 'text');
return (
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
{blocks.map((block) => {
if (block.kind === 'text') {
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
}
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
if (block.kind === 'places') {
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
}
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
return <AssistantChangeCard key={block.key} items={block.items} />;
})}
{/* Nothing below says the answer goes on: no words being written, no step running. */}
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
<p role="status" className="m-0 text-caption">
<span className="trek-assistant-shimmer">{t('assistant.step.working')}</span>
</p>
)}
{hasText && !writing && (
// The bar mounts on hover for older answers; the row keeps its height so nothing jumps.
<div className="-mt-1 h-6">
<ActionBarPrimitive.Root autohide={variant === 'fullscreen' ? 'never' : 'not-last'} className="-ms-1 flex">
<ActionBarPrimitive.Copy aria-label={t('common.copy')} className={BAR_BUTTON}>
<AuiIf condition={(s) => s.message.isCopied}>
<Check size={14} aria-hidden="true" />
</AuiIf>
<AuiIf condition={(s) => !s.message.isCopied}>
<Copy size={14} aria-hidden="true" />
</AuiIf>
</ActionBarPrimitive.Copy>
{share.canShare && <ShareButton share={share} />}
</ActionBarPrimitive.Root>
</div>
)}
{share.canShare && <AssistantShareDialog share={share} />}
<AssistantFollowUps message={message} />
</MessagePrimitive.Root>
);
}
export default function AssistantMessage({ message }: { message: StoredMessage }) {
return message.role === 'user' ? <Question message={message} /> : <Answer message={message} />;
}
@@ -0,0 +1,290 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { getCategoryIcon } from '../shared/categoryIcons';
import { PopoverListPanel } from '../shared/dialogParts';
import { Tooltip } from '../shared/Tooltip';
import { usePopoverList } from '../shared/usePopoverList';
import { useAssistantView } from './assistantViewContext';
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
type Strip = ReturnType<typeof useAssistantPlaceStrip>;
const ACTION =
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
/** "+ Day 3" for the day in focus, a list of the trip's days beside it; "Pick a day" when no day is in focus. */
function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
const { t } = useTranslation();
const { days, focusDay, disabled, blockedKey, adding } = strip;
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
});
const { open, toggle, close, triggerRef, listId } = list;
const height = tall ? 'h-10' : 'h-7';
const busy = adding === place.ref;
const pick = (dayId: number) => {
close();
void strip.suggest(place, dayId);
};
const buttons = (
<span className="inline-flex min-w-0 gap-px">
{focusDay ? (
<>
<button
type="button"
disabled={disabled}
onClick={() => void strip.suggest(place, focusDay.id)}
aria-label={t('assistant.place.addTo', { day: focusDay.label })}
className={`${ACTION} ${height} rounded-e-none px-2`}
>
{busy ? (
<LoaderCircle size={12} aria-hidden="true" className="animate-spin" />
) : (
<Plus size={12} aria-hidden="true" />
)}
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
</button>
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-label={t('assistant.place.pickDay')}
className={`${ACTION} ${height} rounded-s-none px-1.5`}
>
<ChevronDown size={12} aria-hidden="true" />
</button>
</>
) : (
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
className={`${ACTION} ${height} px-2`}
>
<Plus size={12} aria-hidden="true" />
<span className="truncate">{t('assistant.place.pickDay')}</span>
<ChevronDown size={12} aria-hidden="true" className="text-content-faint" />
</button>
)}
</span>
);
return (
<>
{blockedKey ? (
<Tooltip label={t(blockedKey)} placement="top">
{/* A disabled button gets no pointer events; the wrapper carries the reason. */}
<span className="inline-flex min-w-0">{buttons}</span>
</Tooltip>
) : (
buttons
)}
{/* 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>
</>
);
}
type Category = ReturnType<Strip['categoryOf']>;
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
function PlaceMedia({
place,
photo,
category,
}: {
place: AssistantFoundPlace;
photo: string | null;
category: Category;
}) {
const { t } = useTranslation();
const icon = category ? getCategoryIcon(category.icon) : MapPin;
return (
<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 transition-transform duration-300 group-hover/card:scale-[1.04]"
/>
) : (
<span className="grid h-full w-full place-items-center">
{createElement(icon, {
size: 30,
'aria-hidden': true,
style: { color: category?.color ?? 'var(--text-faint)' },
})}
</span>
)}
{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">
{createElement(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>
);
}
/** The card's one way on: "On map" for a place the trip has, "Suggested" once it is, else "+ Day 3". */
function PlaceAction({
place,
strip,
suggested,
tall,
}: {
place: AssistantFoundPlace;
strip: Strip;
suggested: boolean;
tall: boolean;
}): ReactNode {
const { t } = useTranslation();
if (place.inTripPlaceId !== null) {
return (
<Tooltip label={t('assistant.place.inTrip')} placement="top">
<button type="button" onClick={() => strip.show(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
<MapPin size={12} aria-hidden="true" />
{t('assistant.place.showOnMap')}
</button>
</Tooltip>
);
}
if (suggested) {
return (
<span className="inline-flex h-7 items-center gap-1 text-caption text-content-muted">
<Check size={12} aria-hidden="true" className="text-success" />
{t('assistant.place.suggested')}
</span>
);
}
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
}
/**
* 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.
*/
export default function AssistantPlaceCard({
place,
strip,
suggested,
}: {
place: AssistantFoundPlace;
strip: Strip;
suggested: boolean;
}) {
const { t } = useTranslation();
const { variant, density } = useAssistantView();
const ref = useRef<HTMLLIElement>(null);
const photo = useFoundPlacePhoto(place, ref);
const category = strip.categoryOf(place);
const distance = strip.distanceOf(place);
const tall = variant === 'fullscreen';
return (
<li
ref={ref}
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)}
>
<button
type="button"
onClick={() => strip.show(place)}
onFocus={() => strip.hover(place)}
onBlur={() => strip.hover(null)}
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
>
<PlaceMedia place={place} photo={photo} category={category} />
<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-3 pb-3 pt-2.5">
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
</div>
</li>
);
}
@@ -0,0 +1,234 @@
/**
* FE-AI-PLACES-001 to FE-AI-PLACES-008
*
* The strip of places a search found: what a card says, that its picture only
* ever comes from TREK's photo proxy, "+ Day N" for the day in focus or a
* picked one, why it waits while a change is undecided, and the map's part.
*/
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildAssignment,
buildCategory,
buildDay,
buildPlace,
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';
import { useTripStore } from '../../store/tripStore';
import { setForcedOffline } from '../../sync/networkMode';
import AssistantPlaceStrip from './AssistantPlaceStrip';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
vi.mock('../../services/photoService', () => ({
fetchPhoto: vi.fn(),
getCached: vi.fn(() => undefined),
}));
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
const AUTH = useAuthStore.getState();
const RealObserver = globalThis.IntersectionObserver;
const DAY_ONE = buildDay({ id: 31, date: '2026-11-02' });
const DAY_TWO = buildDay({ id: 32, date: '2026-11-03' });
function place(ref: string, extra: Partial<AssistantFoundPlace> = {}): AssistantFoundPlace {
return {
ref,
name: `Trattoria ${ref}`,
address: null,
lat: 41.889,
lng: 12.47,
kind: 'food',
rating: null,
openNow: null,
googlePlaceId: null,
osmId: null,
photoKey: null,
inTripPlaceId: null,
...extra,
};
}
const answer = (parts: AssistantMessage['parts'] = []): AssistantMessage => ({
id: 'msg-5',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-21T08:00:00.000Z',
parts,
});
const addFoundPlace = vi.fn(async () => {});
const requestCamera = vi.fn();
const setPreviewPin = vi.fn();
const onRevealMap = vi.fn();
function seed({ focusDayId = 31, messages = [] as AssistantMessage[] } = {}) {
useAuthStore.setState({ user: buildUser({ id: 1 }), placesPhotosEnabled: true });
useTripStore.setState({
trip: buildTrip({ id: TRIP, user_id: 1 }),
days: [DAY_ONE, DAY_TWO],
assignments: {
'31': [buildAssignment({ order_index: 0, place: buildPlace({ lat: 41.9, lng: 12.47 }) })],
},
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
});
useAssistantStore.setState({
addFoundPlace,
requestCamera,
setPreviewPin,
byTrip: {
[TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7, focus: { dayId: focusDayId }, messages },
},
});
}
function renderStrip(found: AssistantPlaces, message = answer(), view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', density: 'compact', onRevealMap, ...view };
return render(
<AssistantViewContext.Provider value={value}>
<AssistantPlaceStrip found={found} message={message} />
</AssistantViewContext.Provider>
);
}
beforeEach(() => setForcedOffline(false));
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useTripStore.setState(TRIPS, true);
useAuthStore.setState(AUTH, true);
globalThis.IntersectionObserver = RealObserver;
setForcedOffline(false);
vi.clearAllMocks();
});
describe('AssistantPlaceStrip', () => {
it('FE-AI-PLACES-001: a card names the place, its likely category, whether it is open and how far it is', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0', { openNow: true })], more: 0 });
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
expect(screen.getByText('Restaurant')).toBeInTheDocument();
expect(screen.getByText('Open now')).toBeInTheDocument();
expect(screen.getByText('1.2 km from the previous stop')).toBeInTheDocument();
});
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(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 () => {
seed();
globalThis.IntersectionObserver = class {
private readonly callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
}
observe() {
this.callback([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
}
disconnect() {}
unobserve() {}
takeRecords() {
return [];
}
} as unknown as typeof IntersectionObserver;
vi.mocked(fetchPhoto).mockImplementation((key, _id, _lat, _lng, _name, callback) => {
const photoUrl = key === 'good' ? '/api/maps/place-photo/good/bytes' : 'https://evil.example/x.jpg';
callback?.({ photoUrl, thumbDataUrl: null });
});
const { container } = renderStrip({
toolCallId: 's1',
places: [place('s1:0', { photoKey: 'good' }), place('s1:1', { photoKey: 'bad', lat: 40 })],
more: 0,
});
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1));
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/maps/place-photo/good/bytes');
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
});
it('FE-AI-PLACES-004: "+ Day 1" suggests the place for the day in focus', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: /^Add to/ }));
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 31);
});
it('FE-AI-PLACES-005: another day is picked from the list, and without a focus the list is the only way', () => {
seed({ focusDayId: 0 });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: /Pick a day/ }));
fireEvent.click(screen.getByRole('button', { name: /Nov 3/ }));
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 32);
});
it('FE-AI-PLACES-006: waits while a change is undecided, and offline', () => {
const waiting = answer([
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
]);
seed({ messages: [waiting] });
const { unmount } = renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
unmount();
seed();
setForcedOffline(true);
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
});
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
seed();
const suggested = answer([
{
type: 'tool-call',
toolCallId: 'f1',
toolName: 'add_found_place',
args: { lat: 41.889, lng: 12.47 },
approval: { id: 'f1', approved: true },
},
]);
renderStrip(
{ toolCallId: 's1', places: [place('s1:0'), place('s1:1', { lat: 40, inTripPlaceId: 8 })], more: 0 },
suggested,
{ variant: 'fullscreen' }
);
expect(screen.getByText('Suggested')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'On map' }));
expect(onRevealMap).toHaveBeenCalled();
expect(requestCamera).toHaveBeenCalledWith(TRIP, [[40, 12.47]], 'pan', 'user');
});
it('FE-AI-PLACES-008: resting on a card pins it on the map until the pointer leaves', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
const card = screen.getByText('Trattoria s1:0').closest('li');
if (!card) throw new Error('no card');
fireEvent.mouseEnter(card);
expect(setPreviewPin).toHaveBeenLastCalledWith({
tripId: TRIP,
ref: 's1:0',
lat: 41.889,
lng: 12.47,
name: 'Trattoria s1:0',
});
fireEvent.mouseLeave(card);
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
});
});
@@ -0,0 +1,132 @@
import type { AssistantMessage, AssistantPlaces } from '@trek/shared';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import AssistantPlaceCard from './AssistantPlaceCard';
import { suggestedFromStrip } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
/** Cards the strip shows, every one the server sends; what it left out is counted on the tile at its end. */
const SHOWN = 60;
// In the strip's head, never over a card: a press that ends a pixel off still lands on the arrow.
const ARROW =
'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 } {
// A right-to-left strip scrolls into negative numbers; the distance from the start is the same either way.
const from = Math.abs(el.scrollLeft);
return { start: from > 4, end: from + el.clientWidth < el.scrollWidth - 4 };
}
/**
* 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 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();
const { variant } = useAssistantView();
const strip = useAssistantPlaceStrip();
const listRef = useRef<HTMLUListElement>(null);
const [fade, setFade] = useState({ start: false, end: false });
const rest = Math.max(0, found.places.length - SHOWN) + found.more;
const measure = useCallback(() => {
const el = listRef.current;
if (!el) return;
const next = edgesOf(el);
setFade((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
}, []);
useEffect(() => {
measure();
const el = listRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure, found]);
// The pin a card left on the map goes with the strip.
const { hover } = strip;
useEffect(() => () => hover(null), [hover]);
/**
* 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;
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';
// Smooth through the strip's own scroll-behavior, which reduced motion turns off.
el.scrollBy((rtl ? -towards : towards) * cards * step, 0);
};
const total = found.places.length + found.more;
return (
<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.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5 motion-safe:scroll-smooth"
>
{found.places.slice(0, SHOWN).map((place) => (
<AssistantPlaceCard
key={place.ref}
place={place}
strip={strip}
suggested={suggestedFromStrip(message, place)}
/>
))}
{rest > 0 && (
<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>
</section>
);
}
@@ -0,0 +1,259 @@
/**
* 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 { TranslationProvider } from '../../i18n/TranslationContext';
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 translated = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
const off = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
expect(off.result.current.place(louvre)).toEqual([]);
useAssistantStore.setState({ status: { available: true } as never });
const on = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
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,68 @@
import { ChevronDown, ShieldCheck } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../i18n';
import { useAssistantStore } from '../../store/assistantStore';
import { useToast } from '../shared/Toast';
import { providerLabel } from './providerCatalog';
/**
* Who reads along, said once. A user who connected their own provider agreed
* to this while connecting; on the admin's connection the chat says it here,
* above the composer, until the user has read it. A new provider set up by
* the admin asks again, because the answer is stored per provider and host.
*/
export default function AssistantPrivacyBanner() {
const { t } = useTranslation();
const toast = useToast();
const effective = useAssistantStore((s) => s.status?.effective ?? null);
const acked = useAssistantStore((s) => s.status?.privacyAcked ?? true);
const ackPrivacy = useAssistantStore((s) => s.ackPrivacy);
const [details, setDetails] = useState(false);
if (!effective || effective.source !== 'instance' || acked) return null;
const provider = providerLabel(effective.kind, effective.host);
const confirm = async () => {
try {
await ackPrivacy();
} catch {
toast.error(t('assistant.error.generic'));
}
};
return (
<div className="trek-assistant-enter rounded-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 text-caption text-content-secondary">
<div className="flex items-start gap-2.5">
<ShieldCheck size={14} aria-hidden="true" className="mt-0.5 flex-shrink-0 text-content-muted" />
<p className="m-0 min-w-0 flex-1">{t('assistant.privacy.banner', { provider })}</p>
</div>
{details && (
<ul className="m-0 mt-2 list-disc space-y-1 ps-9">
<li>{t('assistant.privacy.shared')}</li>
<li>{t('assistant.privacy.notShared')}</li>
<li>{t('assistant.privacy.provider', { provider })}</li>
</ul>
)}
<div className="mt-2 flex items-center justify-end gap-1">
{!details && (
<button
type="button"
onClick={() => setDetails(true)}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-caption text-content-muted hover:bg-surface-hover hover:text-content"
>
{t('assistant.privacy.what')}
<ChevronDown size={12} aria-hidden="true" />
</button>
)}
<button
type="button"
onClick={() => void confirm()}
className="rounded-md px-2 py-1 text-caption font-medium text-accent-on hover:bg-surface-hover"
>
{t('assistant.privacy.ok')}
</button>
</div>
</div>
);
}
@@ -0,0 +1,52 @@
import { useTranslation } from '../../i18n';
import ToggleSwitch from '../Settings/ToggleSwitch';
import ConfirmDialog from '../shared/ConfirmDialog';
import { SHARE_MAX_CHARS } from './assistantShare';
import type { useAssistantShare } from './useAssistantShare';
/**
* The question before an answer goes to the trip chat: who will see it, the
* text exactly as it will arrive, and whether the applied changes go along.
* Nothing can be edited here; what goes out is what the preview shows. One
* dialog for the window and the phone, which lays it out as a sheet.
*/
export default function AssistantShareDialog({ share }: { share: ReturnType<typeof useAssistantShare> }) {
const { t } = useTranslation();
const { preview } = share;
return (
<ConfirmDialog
isOpen={share.previewing}
onClose={share.close}
onConfirm={() => void share.send()}
title={t('assistant.share.title')}
message={t('assistant.share.who')}
confirmLabel={t('assistant.share.send')}
danger={false}
>
{/* Spaced on its own: the phone's sheet sets the message flush against what follows. */}
<div className="flex flex-col gap-3 pt-2">
<p
dir="auto"
className="m-0 max-h-[40vh] overflow-y-auto whitespace-pre-wrap break-words rounded-xl border border-edge-faint bg-surface-secondary p-3 text-body text-content"
>
{preview.text}
</p>
{preview.hasChanges && (
<label className="flex items-center justify-between gap-3 text-body text-content">
<span>{t('assistant.share.includeChanges')}</span>
<ToggleSwitch
on={share.includeChanges}
onToggle={share.toggleChanges}
label={t('assistant.share.includeChanges')}
/>
</label>
)}
{preview.truncated && (
<p className="m-0 text-caption text-content-muted">
{t('assistant.share.truncated', { count: SHARE_MAX_CHARS })}
</p>
)}
</div>
</ConfirmDialog>
);
}
@@ -0,0 +1,182 @@
/**
* FE-AI-SURF-001 to FE-AI-SURF-006
*
* The chat on the real @assistant-ui runtime, fed from the assistant store:
* the empty chat with its suggestions, stored messages as the user sees
* them, sending from the composer, and the composer holding back while a
* change waits for a decision.
*/
import type { AiStatusResponse, AssistantMessage } from '@trek/shared';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import AssistantSurface from './AssistantSurface';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const AT = '2026-10-21T08:00:00.000Z';
const STATUS: AiStatusResponse = {
available: true,
policy: 'instance',
canConnectOwn: false,
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
own: null,
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
quota: null,
privacyAcked: true,
retention: { mode: 'days', days: 90 },
openrouterOAuth: false,
};
const send = vi.fn(async () => {});
function seed(messages: AssistantMessage[], status: AiStatusResponse = STATUS) {
useAssistantStore.setState({
status,
send,
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages } },
});
}
function renderChat() {
return render(<AssistantSurface tripId={TRIP} tripTitle="Kyoto" variant="window" />);
}
beforeAll(() => {
// jsdom lays nothing out and has no element scrolling; the viewport only needs the call to exist.
Element.prototype.scrollTo = vi.fn() as unknown as typeof Element.prototype.scrollTo;
});
beforeEach(() => send.mockClear());
afterEach(() => useAssistantStore.setState(INITIAL, true));
describe('AssistantSurface', () => {
it('FE-AI-SURF-001: the empty chat asks what to plan and offers questions that change nothing for an unknown trip', async () => {
seed([]);
renderChat();
expect(await screen.findByText('What are we planning?')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Make a packing list for this trip' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Which days are still empty?' }));
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Which days are still empty?'));
});
it('FE-AI-SURF-002: shows the question and the answer, with the steps folded away', async () => {
seed([
{ id: 'q1', role: 'user', status: 'complete', createdAt: AT, parts: [{ type: 'text', text: 'Plan Friday' }] },
{
id: 'a1',
role: 'assistant',
status: 'complete',
createdAt: AT,
parts: [
{ type: 'tool-call', toolCallId: 'r1', toolName: 'get_trip_summary', args: {}, result: { ok: true } },
{ type: 'text', text: 'Here is a **plan**.' },
],
},
]);
renderChat();
expect(await screen.findByText('Plan Friday')).toBeInTheDocument();
expect(screen.getByText('plan')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '1 step' })).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('What are we planning?')).toBeNull();
});
it('FE-AI-SURF-003: Enter sends what was typed, with the trip in the placeholder', async () => {
seed([]);
renderChat();
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
fireEvent.change(input, { target: { value: 'Where should we eat?' } });
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Where should we eat?');
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Where should we eat?'));
});
it('FE-AI-SURF-004: a change waiting for a decision holds the send button back', async () => {
seed([
{
id: 'a1',
role: 'assistant',
status: 'awaiting_approval',
createdAt: AT,
parts: [{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_todo', args: {}, approval: { id: 'w1' } }],
},
]);
renderChat();
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
fireEvent.change(input, { target: { value: 'Something else' } });
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
});
it('FE-AI-SURF-005: on the admin connection the chat says once where messages go', async () => {
seed([], { ...STATUS, privacyAcked: false });
renderChat();
expect(
await screen.findByText('Your messages and the trip data the assistant reads go to OpenAI.')
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'What gets shared?' }));
expect(screen.getByText('Not your password, your files or your other trips.')).toBeInTheDocument();
});
it('FE-AI-SURF-006: an answer shows what its search found and the weather it looked up', async () => {
seed([
{
id: 'a1',
role: 'assistant',
status: 'complete',
createdAt: AT,
parts: [
{ type: 'tool-call', toolCallId: 's1', toolName: 'search_nearby_places', args: {}, result: { ok: true } },
{
type: 'data-trek-places',
data: {
toolCallId: 's1',
more: 0,
places: [
{
ref: 's1:0',
name: 'Nishiki Market',
address: null,
lat: 35.005,
lng: 135.765,
kind: 'food',
rating: null,
openNow: null,
googlePlaceId: null,
osmId: null,
photoKey: null,
inTripPlaceId: null,
},
],
},
},
{
type: 'data-trek-weather',
data: {
days: [
{
dayId: null,
date: '2026-11-02',
main: 'Rain',
tempMax: 14,
tempMin: 9,
rainPct: 70,
kind: 'forecast',
},
],
},
},
{ type: 'text', text: 'Rain on Monday, so the market is a good pick.' },
],
},
]);
renderChat();
expect(await screen.findByRole('region', { name: 'Places found' })).toBeInTheDocument();
expect(screen.getByText('Nishiki Market')).toBeInTheDocument();
expect(screen.getByRole('list', { name: 'Weather for 1 day' })).toBeInTheDocument();
expect(screen.getByText('14°')).toBeInTheDocument();
});
});
@@ -0,0 +1,49 @@
import './assistant.css';
import { AssistantRuntimeProvider } from '@assistant-ui/react';
import { useMemo } from 'react';
import AssistantThread from './AssistantThread';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
import { useAssistantRuntime } from './useAssistantRuntime';
export interface AssistantSurfaceProps extends AssistantView {
/** Named in the composer's placeholder. */
tripTitle: string;
}
/**
* The chat for one trip, the same on the desktop window and the phone screen.
* Its own chunk: this is the only module tree that pulls in @assistant-ui, so
* nobody downloads the chat runtime before opening the assistant.
*/
export default function AssistantSurface({
tripId,
tripTitle,
variant,
density,
onShowOnMap,
onFocusDay,
onRevealMap,
}: AssistantSurfaceProps) {
const runtime = useAssistantRuntime(tripId);
// The phone is always compact; a window is what its width says.
const view = useMemo(
() => ({
tripId,
variant,
density: variant === 'fullscreen' ? ('compact' as const) : (density ?? 'roomy'),
onShowOnMap,
onFocusDay,
onRevealMap,
}),
[tripId, variant, density, onShowOnMap, onFocusDay, onRevealMap]
);
return (
<AssistantRuntimeProvider runtime={runtime}>
<AssistantViewContext.Provider value={view}>
<AssistantThread tripTitle={tripTitle} />
</AssistantViewContext.Provider>
</AssistantRuntimeProvider>
);
}
@@ -0,0 +1,34 @@
import { Suspense } from 'react';
import { lazyWithRetry } from '../../utils/lazyWithRetry';
import ErrorBoundary from '../shared/ErrorBoundary';
import { Spinner } from '../shared/Spinner';
import type { AssistantSurfaceProps } from './AssistantSurface';
/**
* The chat, loaded when it is first shown: the window and the phone screen
* mount this, and the chat runtime arrives in one chunk of its own (kept out
* of the offline precache, the assistant needs the network anyway).
*/
const AssistantSurface = lazyWithRetry(() => import('./AssistantSurface'));
export default function AssistantSurfaceLazy(props: AssistantSurfaceProps) {
const waiting = (
<div className="flex flex-1 items-center justify-center">
<Spinner className="h-5 w-5 border-2 border-edge border-t-[color:var(--text-primary)]" />
</div>
);
return (
// Boundary outside the Suspense, as in VideoPlayerLazy: a rejected chunk flies past Suspense.
<ErrorBoundary
boundaryId="assistant"
level="panel"
variant={props.variant === 'fullscreen' ? 'mobile' : 'desktop'}
resetKeys={[props.tripId]}
>
<Suspense fallback={waiting}>
<AssistantSurface key={props.tripId} {...props} />
</Suspense>
</ErrorBoundary>
);
}
@@ -0,0 +1,91 @@
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import AssistantComposer from './AssistantComposer';
import AssistantError from './AssistantError';
import AssistantMessage from './AssistantMessage';
import AssistantPrivacyBanner from './AssistantPrivacyBanner';
import AssistantWelcome, { AssistantSuggestions } from './AssistantWelcome';
import { useAssistantView } from './assistantViewContext';
import { useAssistantAttach } from './useAssistantAttach';
import { useFollowSelectedDay } from './useAssistantFocus';
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
import { useAssistantSuggestions } from './useAssistantSuggestions';
/**
* The chat itself: the messages, and under them the line that says what went
* wrong, the privacy note and the composer, held at the bottom while the
* messages scroll. A new question moves up to the top of the view, so its
* answer reads from its first line instead of chasing the newest one. A
* booking file or a place of the trip can be dropped anywhere on it.
*/
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
useFollowSelectedDay(tripId);
const phone = variant === 'fullscreen';
const attach = useAssistantAttach(tripId);
const placeDrop = useAssistantPlaceDrop(tripId);
const DropIcon = placeDrop.dragging ? MapPin : FileUp;
// `@` reaches only the trip the planner shows; for another one the hint would promise nothing.
const mentionable = useTripStore((s) => s.trip?.id === tripId);
return (
<ThreadPrimitive.Root
className="relative flex min-h-0 flex-1 flex-col"
{...joinDropZones(attach.dropZone, placeDrop.dropZone)}
>
{(attach.dragging || placeDrop.dragging) && (
<div className="pointer-events-none absolute inset-2 z-20 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-edge bg-surface-elevated text-center backdrop-blur-sm">
<DropIcon size={22} aria-hidden="true" className="text-content-secondary" />
<span className="max-w-[16rem] text-body font-medium text-content">
{t(placeDrop.dragging ? 'assistant.attach.dropPlace' : 'assistant.attach.drop')}
</span>
</div>
)}
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
<AuiIf condition={(s) => s.thread.isEmpty}>
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
{mentionable && (
<p className="m-0 pb-1 text-center text-caption text-content-faint">{t('assistant.mention.hint')}</p>
)}
</AuiIf>
<div role="log" aria-label={t('assistant.window.log')} aria-busy={isRunning} className="pt-2">
<ThreadPrimitive.Messages>
{({ message }) => {
const stored = byId.get(message.id);
return stored ? <AssistantMessage message={stored} /> : null;
}}
</ThreadPrimitive.Messages>
</div>
</ThreadPrimitive.Viewport>
{/* Outside the scroller, so it needs no backdrop of its own: on the glass window a second
surface behind the composer showed as a box. */}
<div className="relative flex flex-shrink-0 flex-col gap-2 px-4 pb-3 pt-2">
<ThreadPrimitive.ScrollToBottom
aria-label={t('assistant.thread.scrollDown')}
className="absolute -top-10 start-1/2 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-edge-faint bg-surface-card text-content-secondary shadow-dropdown hover:text-content disabled:hidden rtl:translate-x-1/2"
>
<ArrowDown size={15} aria-hidden="true" />
</ThreadPrimitive.ScrollToBottom>
{phone && (
<AuiIf condition={(s) => s.thread.isEmpty}>
<AssistantSuggestions suggestions={suggestions} layout="row" />
</AuiIf>
)}
<AssistantError tripId={tripId} />
<AssistantPrivacyBanner />
<AssistantComposer tripId={tripId} tripTitle={tripTitle} />
</div>
</ThreadPrimitive.Root>
);
}
@@ -0,0 +1,47 @@
import { ChevronRight } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../i18n';
import AssistantLiveStep from './AssistantLiveStep';
import { toolStepKey } from './assistantModel';
import type { ToolStep } from './assistantParts';
/**
* What the assistant looked up on the way to its answer. While a step runs it
* says what it does ("Reading your plan", "Searching near Trastevere") and
* shimmers; once the run is done the steps fold into one quiet line that
* opens to the list. Tool names never show: they are the model's words, not
* the user's.
*/
export default function AssistantToolSteps({ steps }: { steps: ToolStep[] }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const running = steps.filter((s) => s.running).pop();
if (running) return <AssistantLiveStep toolCallId={running.toolCallId} toolName={running.toolName} />;
return (
<div className="text-caption text-content-muted">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="-ms-1 inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-caption text-content-muted hover:bg-surface-hover hover:text-content-secondary"
>
<ChevronRight
size={13}
aria-hidden="true"
className={`transition-transform duration-150 rtl:-scale-x-100 ${open ? 'rotate-90' : ''}`}
/>
{t('assistant.step.collapsed', { count: steps.length })}
</button>
{open && (
<ul className="m-0 mt-1 list-none space-y-0.5 border-s border-edge-faint ps-3">
{steps.map((step) => (
<li key={step.toolCallId}>{t(toolStepKey(step.toolName))}</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,61 @@
import { ChevronRight, Luggage } from 'lucide-react';
import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState';
import { Spinner } from '../shared/Spinner';
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
/**
* Outside a trip the assistant first asks which one: the chat, its history
* and its changes all belong to one trip. The running and the next trip lead.
*/
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
const { t } = useTranslation();
const trips = useAssistantTripPicker();
if (trips === null) {
return (
<div className="flex flex-1 items-center justify-center">
<Spinner className="h-5 w-5 border-2 border-edge border-t-[color:var(--text-primary)]" />
</div>
);
}
if (trips.length === 0) {
return (
<div className="flex flex-1 items-center justify-center px-6">
<EmptyState scene="idle" size={72} compact surface="var(--bg-elevated)" title={t('assistant.picker.empty')} />
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
{trips.map((trip) => (
<li key={trip.id}>
<button
type="button"
onClick={() => onPick(trip)}
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
>
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-surface-secondary text-content-muted">
<Luggage size={15} aria-hidden="true" />
</span>
<bdi className="min-w-0 flex-1 truncate text-body text-content">{trip.title}</bdi>
{trip.badge && (
<span className="flex-shrink-0 rounded-full bg-accent-subtle px-2 py-px text-caption text-accent-on">
{t(trip.badge === 'now' ? 'assistant.picker.now' : 'assistant.picker.next')}
</span>
)}
<ChevronRight
size={14}
aria-hidden="true"
className="flex-shrink-0 text-content-faint rtl:-scale-x-100"
/>
</button>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,92 @@
/**
* FE-AI-WEATHER-001 to FE-AI-WEATHER-005
*
* The weather row of an answer: temperatures in the user's unit, a drop only
* where rain is likely, the day in focus framed, and a trip day that selects
* itself in the planner.
*/
import type { AssistantWeatherDay } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import AssistantWeatherStrip from './AssistantWeatherStrip';
import { inUnit } from './assistantModel';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const SETTINGS = useSettingsStore.getState();
function day(date: string, extra: Partial<AssistantWeatherDay> = {}): AssistantWeatherDay {
return { dayId: null, date, main: 'Clear', tempMax: 21, tempMin: 12, rainPct: null, kind: 'forecast', ...extra };
}
function renderStrip(days: AssistantWeatherDay[], view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
return render(
<AssistantViewContext.Provider value={value}>
<AssistantWeatherStrip days={days} />
</AssistantViewContext.Provider>
);
}
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useSettingsStore.setState(SETTINGS, true);
});
describe('AssistantWeatherStrip', () => {
it('FE-AI-WEATHER-001: shows °C in the user unit, without a negative zero', () => {
expect(inUnit(21, false)).toBe(21);
expect(inUnit(21, true)).toBe(70);
expect(inUnit(-0.4, false)).toBe(0);
useSettingsStore.setState({ settings: { ...SETTINGS.settings, temperature_unit: 'fahrenheit' } });
renderStrip([day('2026-11-02')]);
expect(screen.getByText('70°')).toBeInTheDocument();
expect(screen.getByText('54°')).toBeInTheDocument();
});
it('FE-AI-WEATHER-002: a drop shows from 30 % rain on, and a climate figure reads as approximate', () => {
renderStrip([
day('2026-11-02', { rainPct: 29 }),
day('2026-11-03', { rainPct: 60 }),
day('2026-11-04', { kind: 'climate', tempMax: 18 }),
]);
expect(screen.queryByText('29%')).toBeNull();
expect(screen.getByText('60%')).toBeInTheDocument();
expect(screen.getByText('≈18°')).toBeInTheDocument();
expect(screen.getByRole('list', { name: 'Weather for 3 days' })).toBeInTheDocument();
});
it('FE-AI-WEATHER-003: a trip day selects itself in the planner, and the day in focus is marked', () => {
const onFocusDay = vi.fn();
useAssistantStore.setState({
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), focus: { dayId: 31 } } },
});
renderStrip([day('2026-11-02', { dayId: 31 }), day('2026-11-03', { dayId: 32 })], { onFocusDay });
const [first, second] = screen.getAllByRole('button');
expect(first).toHaveAttribute('aria-current', 'true');
expect(second).not.toHaveAttribute('aria-current');
if (second) fireEvent.click(second);
expect(onFocusDay).toHaveBeenCalledWith(32);
});
it('FE-AI-WEATHER-004: without a planner or a trip day a chip is only text', () => {
renderStrip([day('2026-11-02', { dayId: 31 })]);
expect(screen.queryByRole('button')).toBeNull();
renderStrip([day('2026-11-03')], { onFocusDay: vi.fn() });
expect(screen.queryByRole('button')).toBeNull();
});
it('FE-AI-WEATHER-005: a chip grows with three-digit or approximate figures instead of spilling over', () => {
useSettingsStore.setState({ settings: { ...SETTINGS.settings, temperature_unit: 'fahrenheit' } });
renderStrip([day('2026-07-02', { kind: 'climate', tempMax: 39, tempMin: 29 })]);
expect(screen.getByText('≈102°')).toBeInTheDocument();
const chip = screen.getByRole('listitem');
expect(chip.className).toContain('min-w-14');
expect(chip.className.split(' ')).not.toContain('w-14');
});
});
@@ -0,0 +1,119 @@
import type { AssistantWeatherDay } from '@trek/shared';
import { Droplet } from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import { weatherIconFor } from '../Weather/weatherIcons';
import { Tooltip } from '../shared/Tooltip';
import { inUnit } from './assistantModel';
import { useAssistantView } from './assistantViewContext';
/** Rain this likely or more gets its drop. */
const RAIN_SHOWN_PCT = 30;
/** The weekday and the day of the month of a `YYYY-MM-DD` date, read as a calendar date wherever the user is. */
function dateParts(iso: string, locale: string): { weekday: string; day: string } {
const [y, m, d] = iso.split('-').map(Number);
const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
return { weekday: date.toLocaleDateString(locale, { weekday: 'short' }), day: String(date.getDate()) };
}
function WeatherChip({
day,
fahrenheit,
focused,
onPick,
}: {
day: AssistantWeatherDay;
fahrenheit: boolean;
focused: boolean;
onPick: ((dayId: number) => void) | null;
}) {
const { t, locale } = useTranslation();
const icon = weatherIconFor(day.main);
const { weekday, day: dayOfMonth } = dateParts(day.date, locale);
// A climate figure is an average for the date, not a forecast; it says so with "≈".
const approx = day.kind === 'climate' ? '≈' : '';
const max = `${approx}${inUnit(day.tempMax, fahrenheit)}°`;
const min = day.tempMin === null ? null : `${inUnit(day.tempMin, fahrenheit)}°`;
const rainy = day.rainPct !== null && day.rainPct >= RAIN_SHOWN_PCT;
const dayId = day.dayId;
const body = (
<>
<span className="text-caption text-content-muted">
{weekday} {dayOfMonth}
</span>
{createElement(icon, { size: 16, strokeWidth: 1.8, 'aria-hidden': true, className: 'text-content-secondary' })}
<span className="flex items-baseline gap-1 tabular-nums">
<span className="text-body font-semibold text-content">{max}</span>
{min && <span className="text-caption text-content-faint">{min}</span>}
</span>
{rainy && (
<Tooltip label={t('assistant.weather.rain', { pct: day.rainPct ?? 0 })} placement="top">
<span className="inline-flex items-center gap-0.5 text-caption tabular-nums text-content-muted">
<Droplet size={10} aria-hidden="true" />
{day.rainPct}%
</span>
</Tooltip>
)}
</>
);
const look = `flex min-w-14 flex-shrink-0 snap-start flex-col items-center gap-0.5 rounded-lg border px-1 py-1.5 ${
focused ? 'border-edge bg-surface-card' : 'border-transparent bg-surface-secondary'
}`;
const label = `${weekday} ${dayOfMonth}, ${max}${min ? ` / ${min}` : ''}`;
if (onPick && dayId !== null) {
return (
<li className="flex">
<button
type="button"
onClick={() => onPick(dayId)}
aria-label={label}
aria-current={focused || undefined}
className={`${look} transition-colors hover:bg-surface-hover`}
>
{body}
</button>
</li>
);
}
return (
<li aria-label={label} className={look}>
{body}
</li>
);
}
/**
* The weather the assistant looked up, one small chip per day: weekday and
* date, the sky, the high in full and the low in quiet, a drop from 30 % rain
* on. The day in focus is framed; a chip with a trip day selects that day in
* the planner. More than a week scrolls sideways.
*/
export default function AssistantWeatherStrip({ days }: { days: AssistantWeatherDay[] }) {
const { t } = useTranslation();
const { tripId, onFocusDay } = useAssistantView();
const fahrenheit = useSettingsStore((s) => s.settings.temperature_unit) === 'fahrenheit';
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
return (
<ul
aria-label={t('assistant.weather.strip', { count: days.length })}
className="trek-assistant-strip -mx-4 flex snap-x scroll-px-4 gap-1.5 overflow-x-auto px-4"
>
{days.map((day) => (
<WeatherChip
key={day.date}
day={day}
fahrenheit={fahrenheit}
focused={day.dayId !== null && day.dayId === focusDayId}
onPick={onFocusDay ?? null}
/>
))}
</ul>
);
}
@@ -0,0 +1,69 @@
import { useTranslation } from '../../i18n';
import type { WeekDay } from './useWeekDays';
/**
* A plan over several days as one row of small tiles: a day per tile, its
* number of changes in the corner, 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-2 pt-1"
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;
const count = taken ? on : day.ids.length;
const name = `${day.weekday ?? t('assistant.week.day')} ${day.n}`;
return (
<button
key={day.id}
type="button"
disabled={disabled}
aria-pressed={taken}
aria-label={`${name}, ${t('assistant.week.changes', { count })}`}
title={t('assistant.week.changes', { count })}
onClick={() => onToggleDay(day.ids, !taken)}
className={`trek-assistant-enter relative flex min-w-0 flex-col items-center rounded-lg py-1.5 outline-none transition-all focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-default ${
taken
? 'bg-accent text-accent-text shadow-sm hover:-translate-y-px hover:shadow-md'
: 'bg-surface-secondary text-content-faint hover:text-content-muted'
}`}
style={{ animationDelay: `${i * 40}ms` }}
>
<span className={`text-caption leading-tight ${taken ? 'opacity-75' : ''}`}>
{day.weekday ?? t('assistant.week.day')}
</span>
<span className={`text-body font-semibold tabular-nums leading-tight ${taken ? '' : 'line-through'}`}>
{day.n}
</span>
<span
aria-hidden="true"
className={`absolute -end-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full px-1 text-caption font-semibold tabular-nums leading-none ring-2 ring-surface-card ${
taken ? 'bg-surface-card text-content' : 'bg-surface-tertiary text-content-faint'
}`}
>
{count}
</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,79 @@
import { ThreadPrimitive } from '@assistant-ui/react';
import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState';
import AssistantHints, { MAX_VISIBLE_HINTS } from './AssistantHints';
import { useAssistantView } from './assistantViewContext';
import { useAssistantHints } from './useAssistantHints';
import type { ReadySuggestion } from './useAssistantSuggestions';
/** Suggestions left under a full list of hints, so both fit the smallest window. */
const SUGGESTIONS_UNDER_HINTS = 2;
/**
* The empty chat: the mascot, one question, and a few things to ask that fit
* where the trip stands. A suggestion sends itself; there is nothing to edit
* first. On the phone the suggestions sit by the composer instead, where the
* thumb is, so this shows them only when `withSuggestions` asks for it.
* Between the two sit the plan hints, when the trip has any.
*/
export default function AssistantWelcome({
suggestions,
canEdit,
withSuggestions,
}: {
suggestions: ReadySuggestion[];
canEdit: boolean;
withSuggestions: boolean;
}) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
const hints = useAssistantHints(tripId, { withStays: true });
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={t('assistant.empty.title')} />
{!canEdit && (
<p className="m-0 max-w-xs text-center text-caption text-content-muted">{t('assistant.empty.readOnly')}</p>
)}
<AssistantHints view={hints} phone={variant === 'fullscreen'} />
{withSuggestions && offered.length > 0 && <AssistantSuggestions suggestions={offered} layout="stack" />}
</div>
);
}
export function AssistantSuggestions({
suggestions,
layout,
}: {
suggestions: ReadySuggestion[];
/** 'stack' under the mascot, 'row' as a strip of chips over the composer. */
layout: 'stack' | 'row';
}) {
return (
<div
className={
layout === 'stack'
? 'trek-stagger flex w-full max-w-sm flex-col gap-1.5'
: 'trek-stagger -mx-1 flex gap-2 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]'
}
>
{suggestions.map((s) => (
<ThreadPrimitive.Suggestion
key={s.id}
prompt={s.prompt}
send
dir="auto"
className={
layout === 'stack'
? 'rounded-xl border border-edge-faint bg-surface-card px-3.5 py-2.5 text-start text-body text-content transition-colors hover:border-edge hover:bg-surface-hover'
: 'min-h-[44px] flex-shrink-0 whitespace-nowrap rounded-full border border-edge-faint bg-surface-card px-4 text-body text-content'
}
>
{s.prompt}
</ThreadPrimitive.Suggestion>
))}
</div>
);
}
@@ -0,0 +1,188 @@
/**
* FE-AI-WIN-001 to FE-AI-WIN-010
*
* The desktop window around the chat: when it shows at all, the button it
* opens from, the trip picker outside a trip, closing that keeps the chat, and
* Escape, which stops an answer before it closes anything (and leaves the
* header's menu to itself), and where the focus goes on the way. The geometry is
* useDragResize's and has its own tests; here it is a fixed rect.
*/
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { tripRepo } from '../../repo/tripRepo';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import type { Trip } from '../../types';
import AssistantWindow from './AssistantWindow';
vi.mock('../../hooks/useDragResize', () => ({
useDragResize: () => ({
rect: { x: 600, y: 120, width: 400, height: 600 },
active: false,
handleProps: {},
edgeProps: () => ({}),
onKeyDown: () => {},
}),
}));
vi.mock('./AssistantSurfaceLazy', () => ({
default: ({
tripId,
tripTitle,
density,
onFocusDay,
}: {
tripId: number;
tripTitle: string;
density?: string;
onFocusDay?: (dayId: number) => void;
}) => (
<div>
<p>
chat {tripId} {tripTitle}
</p>
<output data-testid="density">{density}</output>
{onFocusDay && (
<button type="button" onClick={() => onFocusDay(12)}>
pick day
</button>
)}
<textarea aria-label="composer" />
</div>
),
}));
const TRIP = { id: 4, title: 'Kyoto' };
const INITIAL = useAssistantStore.getState();
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
const loadStatus = vi.fn(async () => {});
const resume = vi.fn(async () => {});
const cancel = vi.fn();
function seed(window: { open: boolean; minimized: boolean }, isRunning = false) {
// A chat the test before unmounted stops itself on the way out; that call is not this test's.
cancel.mockClear();
useAssistantStore.setState({
status: STATUS,
window,
loadStatus,
resume,
cancel,
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning } },
});
}
beforeEach(() => {
vi.spyOn(tripRepo, 'list').mockResolvedValue({
trips: [{ id: 9, title: 'Lisbon', start_date: '2026-10-01', end_date: '2026-12-01' } as Trip],
archivedTrips: [],
});
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('AssistantWindow', () => {
it('FE-AI-WIN-001: shows nothing at all without a connection, but asks for the status', () => {
seed({ open: true, minimized: false });
useAssistantStore.setState({ status: { ...STATUS, available: false } });
const { container } = render(<AssistantWindow trip={TRIP} />);
expect(container).toBeEmptyDOMElement();
expect(loadStatus).toHaveBeenCalled();
});
it('FE-AI-WIN-002: closed, the planner shows the round button, and it opens the chat on this trip', () => {
seed({ open: false, minimized: false });
render(<AssistantWindow trip={TRIP} />);
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
expect(screen.getByRole('dialog', { name: 'Assistant' })).toHaveTextContent('chat 4 Kyoto');
expect(resume).toHaveBeenCalledWith(TRIP.id);
});
it('FE-AI-WIN-003: closed outside a trip, nothing floats', () => {
seed({ open: false, minimized: false });
const { container } = render(<AssistantWindow trip={null} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
});
it('FE-AI-WIN-005: closed while writing, the chat stays mounted and the round button breathes until it is opened', () => {
seed({ open: true, minimized: true }, true);
render(<AssistantWindow trip={TRIP} />);
expect(screen.getByText('chat 4 Kyoto')).not.toBeVisible();
const button = screen.getByRole('button', { name: 'Assistant' });
expect(button).toHaveAttribute('aria-busy', 'true');
fireEvent.click(button);
expect(useAssistantStore.getState().window.minimized).toBe(false);
expect(cancel).not.toHaveBeenCalled();
});
it('FE-AI-WIN-006: Escape stops a running answer first and only then closes', () => {
seed({ open: true, minimized: false }, true);
render(<AssistantWindow trip={TRIP} />);
const dialog = screen.getByRole('dialog', { name: 'Assistant' });
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(cancel).toHaveBeenCalledWith(TRIP.id);
expect(useAssistantStore.getState().window.open).toBe(true);
act(() =>
useAssistantStore.setState({
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning: false } },
})
);
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(useAssistantStore.getState().window.open).toBe(false);
});
it('FE-AI-WIN-007: the head has one close button, which hides the window and keeps the chat', () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
expect(screen.queryByRole('button', { name: 'Minimize' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(useAssistantStore.getState().window.minimized).toBe(true);
expect(screen.getByText('chat 4 Kyoto')).not.toBeVisible();
});
it('FE-AI-WIN-008: closing hands the focus to the round button, opening hands it back to the composer', async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
const button = await screen.findByRole('button', { name: 'Assistant' });
expect(button).toHaveFocus();
fireEvent.click(button);
await waitFor(() => expect(screen.getByLabelText('composer')).toHaveFocus());
});
it("FE-AI-WIN-009: Escape in the header's menu closes the menu, not the window", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
fireEvent.click(screen.getByRole('button', { name: 'More options' }));
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
expect(useAssistantStore.getState().window.open).toBe(true);
});
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
seed({ open: true, minimized: false });
const onFocusDay = vi.fn();
const { unmount } = render(<AssistantWindow trip={TRIP} onFocusDay={onFocusDay} />);
expect(useAssistantStore.getState().windowRect).toEqual({ x: 600, y: 120, width: 400, height: 600 });
expect(screen.getByTestId('density')).toHaveTextContent('compact');
fireEvent.click(screen.getByRole('button', { name: 'pick day' }));
expect(onFocusDay).toHaveBeenCalledWith(12);
unmount();
expect(useAssistantStore.getState().windowRect).toBeNull();
});
});
@@ -0,0 +1,297 @@
import './assistant.css';
import type { AssistantApplied } from '@trek/shared';
import { Sparkles, X } from 'lucide-react';
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { type ResizeEdge, type WindowRect, type WindowSize, useDragResize } from '../../hooks/useDragResize';
import { useTranslation } from '../../i18n';
import { useAssistantStore } from '../../store/assistantStore';
import AssistantHeader from './AssistantHeader';
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
import AssistantSurfaceLazy from './AssistantSurfaceLazy';
import AssistantTripPicker from './AssistantTripPicker';
import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantThread } from './useAssistantThread';
/**
* The assistant on the desktop: a glass window floating over the app, moved
* by its head, resized from any edge or corner, kept inside the screen. It
* remembers where it was. A double click on the head switches between the
* compact window and a large one; let go near a corner and it settles there;
* Alt+arrows move it and Alt+Shift+arrows resize it from the keyboard.
*
* The head's close button only hides it: the conversation goes on behind it,
* and the planner shows the round button it opens from again, breathing while
* an answer is still being written. Escape stops an answer first, then
* closes, and the focus goes back to where it was.
*
* Mounted once for the whole app; it shows nothing until the assistant has a
* connection this user can use.
*/
interface WindowTrip {
id: number;
title: string;
}
interface AssistantWindowProps {
/** The trip on screen; null outside a trip, where the window asks which one first. */
trip: WindowTrip | null;
/** Shows an applied change in the planner on screen. */
onShowOnMap?: (applied: AssistantApplied) => void;
/** Selects a day in the planner on screen. */
onFocusDay?: (dayId: number) => void;
}
/** Narrower than this, the chat lays its cards out compact. */
const ROOMY_WIDTH = 480;
/** Where the window's place and size are kept between visits. */
const WINDOW_STORAGE = 'trek.assistantWindow';
const MIN_SIZE: WindowSize = { width: 340, height: 420 };
const DEFAULT_SIZE: WindowSize = { width: 400, height: 600 };
/** Never closer to the screen's edge than this. */
const MARGIN = 16;
/** The default distance from the edges, and how near a corner a released window snaps into it. */
const GAP = 24;
const expandedSize = (viewport: WindowSize): WindowSize => ({
width: Math.min(720, viewport.width - 2 * MARGIN),
height: Math.round(viewport.height * 0.8),
});
const EDGES: { edge: ResizeEdge; className: string; cursor: 'ns' | 'ew' | 'corner-start' | 'corner-end' }[] = [
{ edge: 'n', className: 'inset-x-3 -top-1 h-2', cursor: 'ns' },
{ edge: 's', className: 'inset-x-3 -bottom-1 h-2', cursor: 'ns' },
{ edge: 'start', className: 'inset-y-3 -start-1 w-2', cursor: 'ew' },
{ edge: 'end', className: 'inset-y-3 -end-1 w-2', cursor: 'ew' },
{ edge: 'n-start', className: '-start-1 -top-1 h-4 w-4', cursor: 'corner-start' },
{ edge: 's-end', className: '-bottom-1 -end-1 h-4 w-4', cursor: 'corner-start' },
{ edge: 'n-end', className: '-end-1 -top-1 h-4 w-4', cursor: 'corner-end' },
{ edge: 's-start', className: '-bottom-1 -start-1 h-4 w-4', cursor: 'corner-end' },
];
function edgeCursor(kind: (typeof EDGES)[number]['cursor'], rtl: boolean): string {
if (kind === 'ns') return 'ns-resize';
if (kind === 'ew') return 'ew-resize';
// The top-start corner is the top left in a left-to-right page and the top right in Arabic.
return (kind === 'corner-start') !== rtl ? 'nwse-resize' : 'nesw-resize';
}
/** The fixed navbar's height (`--nav-h`, a calc() and 0 where there is none), measured. */
function navbarHeight(): number {
if (typeof document === 'undefined') return 0;
const probe = document.createElement('div');
probe.style.cssText = 'position:fixed;visibility:hidden;height:var(--nav-h, 0px)';
document.body.appendChild(probe);
const height = probe.offsetHeight;
probe.remove();
return height;
}
/** Where the floating button's middle sits inside the window, so the window grows out of it. */
function originFrom(rect: WindowRect, rtl: boolean): string {
const fromStart = window.innerWidth - LAUNCHER_INSET - LAUNCHER_SIZE / 2 - rect.x;
const x = rtl ? rect.width - fromStart : fromStart;
const y = window.innerHeight - LAUNCHER_INSET - LAUNCHER_SIZE / 2 - rect.y;
return `${Math.round(x)}px ${Math.round(y)}px`;
}
function ChatWindow({
trip,
hidden,
onShowOnMap,
onFocusDay,
}: {
trip: WindowTrip;
hidden: boolean;
onShowOnMap?: (applied: AssistantApplied) => void;
onFocusDay?: (dayId: number) => void;
}) {
const { t } = useTranslation();
const thread = useAssistantThread(trip.id);
const close = useAssistantStore((s) => s.close);
const setMinimized = useAssistantStore((s) => s.setMinimized);
const setWindowRect = useAssistantStore((s) => s.setWindowRect);
// The navbar sits on the same layer and later in the page: the head never goes under it.
const [topInset] = useState(navbarHeight);
const frame = useDragResize({
storageKey: WINDOW_STORAGE,
minSize: MIN_SIZE,
defaultSize: DEFAULT_SIZE,
expandedSize,
margin: MARGIN,
gap: GAP,
topInset,
});
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
const [origin] = useState(() => originFrom(frame.rect, rtl));
const returnFocus = useRef<Element | null>(typeof document !== 'undefined' ? document.activeElement : null);
const sectionRef = useRef<HTMLElement>(null);
const wasHidden = useRef(hidden);
const { x, y, width, height } = frame.rect;
// The map frames its view around the window: it learns where the window
// settled after a move or a resize, not every step of the drag.
useEffect(() => {
if (hidden) setWindowRect(null);
else if (!frame.active) setWindowRect({ x, y, width, height });
}, [hidden, frame.active, x, y, width, height, setWindowRect]);
useEffect(() => () => setWindowRect(null), [setWindowRect]);
// Back to whatever had the focus before the window opened.
useEffect(() => {
const target = returnFocus.current;
return () => {
if (target instanceof HTMLElement && target.isConnected) target.focus();
};
}, []);
// Restored from the pill: back into the composer, where the user left off.
useEffect(() => {
if (wasHidden.current && !hidden) sectionRef.current?.querySelector('textarea')?.focus();
wasHidden.current = hidden;
}, [hidden]);
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
// Keys from a portal (the header's menu) bubble here through React; they are not the window's.
if (!e.currentTarget.contains(e.target as Node)) return;
frame.onKeyDown(e);
if (e.key !== 'Escape' || e.defaultPrevented) return;
e.preventDefault();
if (thread.isRunning) thread.stop();
else close();
};
const style: CSSProperties = {
insetInlineStart: frame.rect.x,
top: frame.rect.y,
width: frame.rect.width,
height: frame.rect.height,
transformOrigin: origin,
};
return (
<section
ref={sectionRef}
role="dialog"
aria-label={t('assistant.window.title')}
// Hidden, not unmounted, while minimized: the answer being written keeps coming in.
hidden={hidden}
onKeyDown={onKeyDown}
style={style}
className={`trek-assistant-pop fixed z-[var(--z-nav)] ${hidden ? 'hidden' : 'flex'} flex-col overflow-visible rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150 ${
frame.active ? 'select-none' : 'trek-assistant-settle'
}`}
>
{EDGES.map(({ edge, className, cursor }) => (
<div
key={edge}
aria-hidden="true"
{...frame.edgeProps(edge)}
className={`absolute z-10 touch-none ${className}`}
style={{ cursor: edgeCursor(cursor, rtl) }}
/>
))}
<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}
// Closing only hides the window: an answer being written keeps coming in,
// and the floating button says so until it is opened again.
onClose={() => setMinimized(true)}
/>
<AssistantSurfaceLazy
tripId={trip.id}
tripTitle={trip.title}
variant="window"
density={width < ROOMY_WIDTH ? 'compact' : 'roomy'}
onShowOnMap={onShowOnMap}
onFocusDay={onFocusDay}
/>
</div>
</section>
);
}
export default function AssistantWindow({ trip, onShowOnMap, onFocusDay }: AssistantWindowProps) {
const navigate = useNavigate();
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open);
const minimized = useAssistantStore((s) => s.window.minimized);
const loadStatus = useAssistantStore((s) => s.loadStatus);
const toggle = useAssistantStore((s) => s.toggle);
const [picked, setPicked] = useState<WindowTrip | null>(null);
const [wasOpen, setWasOpen] = useState(open);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useAssistantHotkey(available, toggle);
// A trip picked outside the planner is only for this opening; the next one asks again.
if (open !== wasOpen) {
setWasOpen(open);
if (!open) setPicked(null);
}
const current = trip ?? picked;
const showPicked = useCallback(() => {
if (picked) void navigate(`/trips/${picked.id}`);
}, [navigate, picked]);
if (!available) return null;
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
if (!current) return <PickerWindow onPick={setPicked} />;
// A change in a trip the planner does not show is shown by opening that trip.
const onScreen = !!trip && current.id === trip.id;
const showOnMap = onScreen ? onShowOnMap : showPicked;
return (
<>
<ChatWindow
trip={current}
hidden={minimized}
onShowOnMap={showOnMap}
// Only the planner on screen has days to select.
onFocusDay={onScreen ? onFocusDay : undefined}
/>
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
</>
);
}
function PickerWindow({ onPick }: { onPick: (trip: WindowTrip) => void }) {
const { t } = useTranslation();
const close = useAssistantStore((s) => s.close);
return (
<section
role="dialog"
aria-labelledby="assistant-picker-title"
onKeyDown={(e) => {
if (e.key === 'Escape' && !e.defaultPrevented) close();
}}
className="trek-assistant-pop fixed z-[var(--z-nav)] flex max-h-[min(32rem,calc(100vh-6rem))] w-[22rem] flex-col overflow-hidden rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150"
style={{ insetInlineEnd: LAUNCHER_INSET, bottom: LAUNCHER_INSET, transformOrigin: 'bottom' }}
>
<header className="flex h-10 flex-shrink-0 items-center gap-2 border-b border-edge-faint pe-1.5 ps-3">
<Sparkles size={14} aria-hidden="true" className="flex-shrink-0 text-content-muted" />
<h2 id="assistant-picker-title" className="m-0 flex-1 truncate text-body font-semibold text-content">
{t('assistant.picker.title')}
</h2>
<button
type="button"
onClick={close}
aria-label={t('common.close')}
className="flex h-7 w-7 items-center justify-center rounded-md text-content-muted hover:bg-surface-hover hover:text-content"
>
<X size={16} aria-hidden="true" />
</button>
</header>
<AssistantTripPicker onPick={({ id, title }) => onPick({ id, title })} />
</section>
);
}
@@ -0,0 +1,273 @@
/* The assistant's motion. Calm on purpose: things arrive, nothing bounces.
Every rule here has a still form under reduced motion, from the OS setting
or from the user's own switch (data-reduce-motion on <html>). */
/* The window grows out of the button that opened it. The origin is set inline,
because it is wherever the button sits relative to the window. */
@keyframes trek-assistant-pop {
from {
opacity: 0;
transform: scale(0.94);
}
to {
opacity: 1;
transform: scale(1);
}
}
.trek-assistant-pop {
animation: trek-assistant-pop 240ms var(--ease-drawer);
}
/* Settling after a drag: the window glides into a corner or its other size,
but never while a pointer is moving it. */
.trek-assistant-settle {
transition-property: inset-inline-start, top, width, height;
transition-duration: 220ms;
transition-timing-function: var(--ease-drawer);
}
/* A new message rises in. */
.trek-assistant-enter {
animation: trek-fade-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
/* A reading step names what it does while it runs: the words shimmer. */
.trek-assistant-shimmer {
background-image: linear-gradient(
90deg,
var(--text-muted) 0%,
var(--text-muted) 40%,
var(--text-primary) 50%,
var(--text-muted) 60%,
var(--text-muted) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: trek-shimmer 1.8s linear infinite;
}
/* The caret at the end of an answer that is still being written: after the
last word, so in the last item of a closing list. */
@keyframes trek-assistant-caret {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.2;
}
}
.trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
.trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
content: '';
display: inline-block;
width: 2px;
height: 1em;
margin-inline-start: 2px;
vertical-align: -0.15em;
border-radius: 1px;
background: var(--text-primary);
animation: trek-assistant-caret 1s ease-in-out infinite;
}
/* Markdown in an answer: the planner's text scale, tight rhythm, no images. */
.trek-assistant-md > :first-child {
margin-top: 0;
}
.trek-assistant-md > :last-child {
margin-bottom: 0;
}
.trek-assistant-md p,
.trek-assistant-md ul,
.trek-assistant-md ol,
.trek-assistant-md pre,
.trek-assistant-md blockquote,
.trek-assistant-md table {
margin: 0.5em 0;
}
.trek-assistant-md ul {
list-style: disc;
padding-inline-start: 1.25em;
}
.trek-assistant-md ol {
list-style: decimal;
padding-inline-start: 1.25em;
}
.trek-assistant-md li + li {
margin-top: 0.15em;
}
.trek-assistant-md h1,
.trek-assistant-md h2,
.trek-assistant-md h3,
.trek-assistant-md h4 {
margin: 0.75em 0 0.25em;
font-weight: 600;
font-size: inherit;
}
.trek-assistant-md strong {
font-weight: 600;
}
.trek-assistant-md a {
color: var(--accent-on);
text-decoration: underline;
text-underline-offset: 2px;
}
.trek-assistant-md code {
padding: 0.05em 0.3em;
border-radius: 4px;
background: var(--bg-tertiary);
font-size: 0.92em;
}
.trek-assistant-md pre {
overflow-x: auto;
padding: 0.6em 0.75em;
border-radius: 8px;
background: var(--bg-tertiary);
}
.trek-assistant-md pre code {
padding: 0;
background: none;
}
.trek-assistant-md blockquote {
padding-inline-start: 0.75em;
border-inline-start: 2px solid var(--border-primary);
color: var(--text-secondary);
}
.trek-assistant-md table {
display: block;
overflow-x: auto;
border-collapse: collapse;
}
.trek-assistant-md th,
.trek-assistant-md td {
padding: 0.25em 0.6em;
border: 1px solid var(--border-faint);
text-align: start;
}
.trek-assistant-md hr {
margin: 0.75em 0;
border: 0;
border-top: 1px solid var(--border-faint);
}
/* A closed window still writing an answer: a ring breathes around the floating button. */
@keyframes trek-assistant-busy {
0%,
100% {
transform: scale(1);
opacity: 0.45;
}
50% {
transform: scale(1.18);
opacity: 0;
}
}
.trek-assistant-busy {
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 {
animation: trek-backdrop-enter 120ms ease-out;
}
.trek-assistant-settle {
transition: none;
}
.trek-assistant-busy,
.trek-assistant-pulse {
animation: none;
}
.trek-assistant-shimmer {
animation: none;
background: none;
color: var(--text-muted);
}
.trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
.trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
animation: none;
}
}
[data-reduce-motion] .trek-assistant-pop,
[data-reduce-motion] .trek-assistant-enter {
animation: trek-backdrop-enter 120ms ease-out;
}
[data-reduce-motion] .trek-assistant-settle {
transition: none;
}
[data-reduce-motion] .trek-assistant-busy,
[data-reduce-motion] .trek-assistant-pulse {
animation: none;
}
[data-reduce-motion] .trek-assistant-shimmer {
animation: none;
background: none;
color: var(--text-muted);
}
[data-reduce-motion] .trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
[data-reduce-motion] .trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
animation: none;
}
/* A row of cards that scrolls sideways (found places, the weather): no bar,
and the edges fade where there is more to scroll to. */
.trek-assistant-strip {
--strip-fade-start: 0px;
--strip-fade-end: 0px;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(
to right,
transparent,
black var(--strip-fade-start),
black calc(100% - var(--strip-fade-end)),
transparent
);
mask-image: linear-gradient(
to right,
transparent,
black var(--strip-fade-start),
black calc(100% - var(--strip-fade-end)),
transparent
);
}
.trek-assistant-strip::-webkit-scrollbar {
display: none;
}
[dir='rtl'] .trek-assistant-strip {
-webkit-mask-image: linear-gradient(
to left,
transparent,
black var(--strip-fade-start),
black calc(100% - var(--strip-fade-end)),
transparent
);
mask-image: linear-gradient(
to left,
transparent,
black var(--strip-fade-start),
black calc(100% - var(--strip-fade-end)),
transparent
);
}
.trek-assistant-strip[data-fade-start] {
--strip-fade-start: 24px;
}
.trek-assistant-strip[data-fade-end] {
--strip-fade-end: 24px;
}
@@ -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 : [];
}
@@ -0,0 +1,372 @@
/**
* FE-AI-HINT-001 to FE-AI-HINT-019
*
* The plan hints, worked out from the trip alone: each rule on both sides of
* its threshold, the data that silences it, and the order, ids and clock
* helpers they rest on.
*/
import type { PlaceOpeningHours } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import type { Accommodation, Assignment, Day, Place, Reservation } from '../../types';
import { formatClockTime } from '../../utils/formatters';
import { type AssistantHint, GAP_MIN_MINUTES, LONG_LEG_KM, tripHints, type TripHintsInput } from './assistantHintRules';
import {
bookingSpan,
clockMinutes,
clockText,
noteMinutes,
spansOverlap,
stopSpan,
weekdayMondayFirst,
} from './assistantHintsTime';
const TODAY = '2026-06-10';
/** A Friday. */
const FRIDAY = '2026-06-12';
/** One kilometre north, in degrees of latitude on the sphere haversineKm uses. */
const KM = 1 / ((2 * Math.PI * 6371) / 360);
function day(id: number, date: string | null = FRIDAY, extra: Partial<Day> = {}): Day {
return buildDay({ id, date, ...extra });
}
interface StopOptions {
time?: string;
end?: string;
duration?: number;
lat?: number;
place?: Partial<Place>;
}
function stop(id: number, dayId: number, opts: StopOptions = {}, extra: Partial<Assignment> = {}): Assignment {
const place = buildPlace({
id: 1000 + id,
name: `Stop ${id}`,
place_time: opts.time ?? null,
end_time: opts.end ?? null,
duration_minutes: opts.duration ?? null,
lat: opts.lat ?? 0,
lng: 0,
...opts.place,
});
return buildAssignment({ id, day_id: dayId, place_id: place.id, order_index: id, place, ...extra });
}
function input(over: Partial<TripHintsInput> & { stops?: Assignment[] }): TripHintsInput {
const { stops = [], ...rest } = over;
const assignments: TripHintsInput['assignments'] = {};
for (const a of stops) (assignments[String(a.day_id)] ??= []).push(a);
return {
today: TODAY,
days: [day(1)],
assignments,
places: stops.map((a) => a.place as Place),
reservations: [],
dayNotes: {},
accommodations: [],
distanceUnit: 'metric',
is12h: false,
...rest,
};
}
function booking(over: Partial<Reservation>): Reservation {
return buildReservation({ day_id: 1, ...over });
}
function stay(start: number, end: number): Accommodation {
return { id: start * 100 + end, trip_id: 1, start_day_id: start, end_day_id: end };
}
function hours(closedOn: number | null, open = '09:00', close = '17:00'): string {
const week: PlaceOpeningHours = Array.from({ length: 7 }, (_, i) =>
i === closedOn ? { closed: true } : { closed: false, open, close }
);
return JSON.stringify(week);
}
const only = (hints: AssistantHint[], rule: AssistantHint['rule']) => hints.filter((h) => h.rule === rule);
describe('overlap', () => {
it('FE-AI-HINT-001: names two stops of a day that share time, once per day, as a warning', () => {
const hints = tripHints(
input({
stops: [
stop(1, 1, { time: '10:00', end: '12:00' }),
stop(2, 1, { time: '11:00', duration: 60 }),
stop(3, 1, { time: '11:30', end: '12:30' }),
],
})
);
const overlaps = only(hints, 'overlap');
expect(overlaps).toHaveLength(1);
expect(overlaps[0]).toMatchObject({
level: 'warn',
dayId: 1,
textKey: 'assistant.hint.overlap',
promptKey: 'assistant.hint.overlap.prompt',
params: { a: 'Stop 1', b: 'Stop 2' },
focus: { dayId: 1, placeIds: [1001, 1002] },
});
});
it('FE-AI-HINT-002: stops that only touch, two bare starts, and a stop timed apart by its own assignment do not overlap', () => {
const overlaps = (stops: Assignment[]) => only(tripHints(input({ stops })), 'overlap').length;
const ten = () => stop(1, 1, { time: '10:00', end: '11:00' });
expect(overlaps([ten(), stop(2, 1, { time: '11:00' })])).toBe(0);
expect(overlaps([stop(1, 1, { time: '10:00' }), stop(2, 1, { time: '10:00' })])).toBe(0);
expect(overlaps([ten(), stop(2, 1, { time: '10:30', duration: 30 }, { assignment_time: '14:00' })])).toBe(0);
expect(overlaps([ten(), stop(2, 1, { time: '10:30' })])).toBe(1);
});
it('FE-AI-HINT-003: collides a stop with a booking that keeps the traveller busy, but not with its own booking', () => {
const lunch = booking({
id: 7,
title: 'Lunch',
reservation_time: `${FRIDAY}T12:30`,
reservation_end_time: `${FRIDAY}T14:00`,
});
const museum = stop(1, 1, { time: '13:00', duration: 60 });
const [hint] = only(tripHints(input({ stops: [museum], reservations: [lunch] })), 'overlap');
expect(hint.params).toEqual({ a: 'Lunch', b: 'Stop 1' });
expect(hint.focus).toEqual({ dayId: 1, placeIds: [1001] });
const quiet = [
{ ...lunch, assignment_id: 1 },
{ ...lunch, type: 'parking' },
{ ...lunch, status: 'cancelled' },
{ ...lunch, ingest_state: 'staged' },
{ ...lunch, reservation_time: '2026-06-13T12:30' },
];
for (const r of quiet) {
expect(only(tripHints(input({ stops: [museum], reservations: [r] })), 'overlap')).toEqual([]);
}
});
});
describe('opening hours', () => {
it('FE-AI-HINT-004: warns about a place that is closed on the weekday of its day, and only on a dated day', () => {
const closed = stop(1, 1, { place: { opening_hours: hours(4) } });
const [hint] = tripHints(input({ stops: [closed] }));
expect(hint).toMatchObject({
rule: 'closed',
level: 'warn',
textKey: 'assistant.hint.closed',
params: { place: 'Stop 1' },
focus: { dayId: 1, placeIds: [1001] },
});
expect(tripHints(input({ days: [day(1, null)], stops: [closed] }))).toEqual([]);
expect(tripHints(input({ days: [day(1, '2026-06-15')], stops: [closed] }))).toEqual([]);
});
it('FE-AI-HINT-005: says when a visit starts before opening or runs past closing, and leaves hours past midnight alone', () => {
const early = stop(1, 1, { time: '08:00', duration: 60, place: { opening_hours: hours(null) } });
expect(tripHints(input({ stops: [early] }))[0]).toMatchObject({
rule: 'outsideHours',
textKey: 'assistant.hint.opensLate',
promptKey: 'assistant.hint.outsideHours.prompt',
params: { place: 'Stop 1', time: '09:00' },
});
const late = stop(1, 1, { time: '16:00', duration: 120, place: { opening_hours: hours(null) } });
expect(tripHints(input({ stops: [late] }))[0]).toMatchObject({
textKey: 'assistant.hint.closesEarly',
params: { time: '17:00' },
});
const fits = stop(1, 1, { time: '10:00', duration: 120, place: { opening_hours: hours(null) } });
const night = stop(1, 1, { time: '08:00', place: { opening_hours: hours(null, '20:00', '02:00') } });
expect(tripHints(input({ stops: [fits] }))).toEqual([]);
expect(tripHints(input({ stops: [night] }))).toEqual([]);
});
});
describe('gaps', () => {
const morning = () => stop(1, 1, { time: '09:00', end: '10:00' });
it('FE-AI-HINT-006: points at three free hours between two timed stops, in the user clock', () => {
const [hint] = tripHints(input({ stops: [morning(), stop(2, 1, { time: '13:00' })] }));
expect(hint).toMatchObject({
rule: 'gap',
level: 'info',
focus: { dayId: 1 },
params: { count: 3, from: '10:00', to: '13:00' },
});
const twelve = tripHints(input({ stops: [morning(), stop(2, 1, { time: '13:00' })], is12h: true }))[0];
expect(twelve.params.from).toBe(formatClockTime('10:00', true));
expect(GAP_MIN_MINUTES).toBe(180);
expect(tripHints(input({ stops: [morning(), stop(2, 1, { time: '12:59' })] }))).toEqual([]);
});
it('FE-AI-HINT-007: a timed note, a booking in between or an unknown end fills the gap; a day without a date still has one', () => {
const later = stop(2, 1, { time: '14:00' });
const note = { id: 1, day_id: 1, text: 'Picnic', time: '11:30' };
expect(tripHints(input({ stops: [morning(), later], dayNotes: { '1': [note] } }))).toEqual([]);
const tour = booking({ type: 'tour', reservation_time: `${FRIDAY}T11:00` });
expect(tripHints(input({ stops: [morning(), later], reservations: [tour] }))).toEqual([]);
expect(tripHints(input({ stops: [stop(1, 1, { time: '09:00' }), later] }))).toEqual([]);
const undated = tripHints(input({ days: [day(1, null)], stops: [morning(), later] }));
expect(undated.map((h) => h.rule)).toEqual(['gap']);
});
});
describe('legs', () => {
it('FE-AI-HINT-008: calls a walk of more than 4 km as the crow flies far, and a ride by bike only past 20 km', () => {
const near = [stop(1, 1), stop(2, 1, { lat: 4 * KM - 1e-9 })];
const far = [stop(1, 1), stop(2, 1, { lat: 4.01 * KM })];
expect(LONG_LEG_KM.walking).toBe(4);
expect(tripHints(input({ stops: near }))).toEqual([]);
expect(tripHints(input({ stops: far }))[0]).toMatchObject({
rule: 'longLeg',
level: 'info',
params: { from: 'Stop 1', to: 'Stop 2', distance: '4 km' },
focus: { dayId: 1, placeIds: [1001, 1002] },
});
const byBike = [stop(1, 1, {}, { leg_transport_mode: 'cycling' }), stop(2, 1, { lat: 15 * KM })];
expect(tripHints(input({ stops: byBike }))).toEqual([]);
const byCar = [stop(1, 1), stop(2, 1, { lat: 100 * KM })];
const driving = day(1, FRIDAY, { default_transport_mode: 'driving' });
expect(tripHints(input({ days: [driving], stops: byCar }))).toEqual([]);
});
it('FE-AI-HINT-009: leaves out stops off the route, road trip and tour stops, and days with a flight', () => {
const far = (extra: Partial<Assignment>, place: Partial<Place> = {}) => [
stop(1, 1),
stop(2, 1, { lat: 10 * KM, place }, extra),
];
expect(tripHints(input({ stops: far({ route_excluded: true }) }))).toEqual([]);
expect(tripHints(input({ stops: far({ tour_place_id: 9 }) }))).toEqual([]);
expect(tripHints(input({ stops: far({}, { stop_type: 'fuel' }) }))).toEqual([]);
const flight = booking({ type: 'flight' });
expect(tripHints(input({ stops: far({}), reservations: [flight] }))).toEqual([]);
});
it('FE-AI-HINT-010: warns when the time between two stops is too short for the way, naming the minutes there are', () => {
const first = stop(1, 1, { time: '10:00', end: '11:00' });
const tight = tripHints(input({ stops: [first, stop(2, 1, { time: '11:30', lat: 3 * KM })] }));
expect(tight[0]).toMatchObject({
rule: 'tightLeg',
level: 'warn',
textKey: 'assistant.hint.tightLeg',
params: { count: 30, from: 'Stop 1', to: 'Stop 2' },
});
expect(tripHints(input({ stops: [first, stop(2, 1, { time: '11:45', lat: 3 * KM })] }))).toEqual([]);
});
});
describe('nights', () => {
const days = [day(1, '2026-06-11'), day(2, FRIDAY), day(3, '2026-06-13'), day(4, '2026-06-14')];
it('FE-AI-HINT-011: joins nights in a row without a stay into one hint, from their first day', () => {
const hints = only(tripHints(input({ days, accommodations: [stay(1, 2)] })), 'noStay');
expect(hints).toHaveLength(1);
expect(hints[0]).toMatchObject({ dayId: 2, dayIndex: 1, params: { count: 2 }, focus: { dayId: 2 } });
expect(only(tripHints(input({ days, accommodations: [stay(1, 4)] })), 'noStay')).toEqual([]);
});
it('FE-AI-HINT-012: a ride through the night covers it; unknown stays, one-day trips and past nights say nothing', () => {
const train = booking({ type: 'train', day_id: 2, end_day_id: 3 });
const hints = only(tripHints(input({ days, accommodations: [stay(1, 2)], reservations: [train] })), 'noStay');
expect(hints.map((h) => [h.dayId, h.params.count])).toEqual([[3, 1]]);
expect(only(tripHints(input({ days, accommodations: null })), 'noStay')).toEqual([]);
expect(only(tripHints(input({ days: [day(1)], accommodations: [] })), 'noStay')).toEqual([]);
const past = [day(1, '2026-06-08'), day(2, '2026-06-09'), day(3, TODAY)];
expect(only(tripHints(input({ days: past, accommodations: [] })), 'noStay')).toEqual([]);
});
});
describe('bookings without a day', () => {
it('FE-AI-HINT-013: names one loose booking and its day, or counts several', () => {
const ferry = booking({ id: 5, title: 'Ferry', day_id: null, reservation_time: `${FRIDAY}T08:00` });
expect(tripHints(input({ reservations: [ferry] }))[0]).toMatchObject({
rule: 'unplacedBooking',
textKey: 'assistant.hint.unplacedBookingDay',
dayId: 1,
focus: { dayId: 1 },
params: { title: 'Ferry' },
});
const undated = { ...ferry, reservation_time: null };
expect(tripHints(input({ reservations: [undated] }))[0]).toMatchObject({
textKey: 'assistant.hint.unplacedBooking',
dayId: null,
focus: null,
});
const two = tripHints(input({ reservations: [ferry, { ...undated, id: 6 }] }));
expect(two[0]).toMatchObject({ textKey: 'assistant.hint.unplacedBookings', params: { count: 2 } });
expect(two[0].id).toBe('unplacedBooking:trip:5,6');
});
it('FE-AI-HINT-014: leaves hotels, cancelled, staged, past and stop-bound bookings alone', () => {
const loose = { day_id: null, reservation_time: null };
const quiet = [
booking({ ...loose, type: 'hotel' }),
booking({ ...loose, status: 'cancelled' }),
booking({ ...loose, ingest_state: 'staged' }),
booking({ ...loose, reservation_time: '2026-06-01T10:00' }),
booking({ ...loose, assignment_id: 3 }),
];
expect(tripHints(input({ reservations: quiet }))).toEqual([]);
});
});
describe('the list', () => {
it('FE-AI-HINT-015: puts warnings first, then by day; past days and stay stops are not read', () => {
const hints = tripHints(
input({
days: [day(1, '2026-06-09'), day(2, '2026-06-11'), day(3, FRIDAY)],
stops: [
stop(1, 1, { time: '10:00', end: '12:00' }),
stop(2, 1, { time: '11:00', end: '12:00' }),
stop(3, 2, { time: '09:00', end: '10:00' }),
stop(4, 2, { time: '14:00' }),
stop(5, 3, { time: '10:00', end: '12:00' }),
stop(6, 3, { time: '11:00', end: '12:00' }, { accommodation_id: 1 }),
stop(7, 3, { time: '11:30', end: '13:00' }),
],
accommodations: [stay(1, 3)],
})
);
expect(hints.map((h) => [h.rule, h.dayId])).toEqual([
['overlap', 3],
['gap', 2],
]);
expect(hints[0].params).toEqual({ a: 'Stop 5', b: 'Stop 7' });
});
it('FE-AI-HINT-016: keeps an id while the plan stays the same and changes it with the times', () => {
const plan = (end: string) =>
input({ stops: [stop(1, 1, { time: '10:00', end }), stop(2, 1, { time: '11:00', end: '12:00' })] });
expect(tripHints(plan('12:00'))[0].id).toBe(tripHints(plan('12:00'))[0].id);
expect(tripHints(plan('12:00'))[0].id).not.toBe(tripHints(plan('11:30'))[0].id);
});
});
describe('the clock', () => {
it('FE-AI-HINT-017: reads HH:MM only, and a note by the time it starts with', () => {
expect(clockMinutes('9:05')).toBe(545);
expect(clockMinutes(' 23:59 ')).toBe(1439);
for (const value of ['24:00', '10:60', '09:30:00', null]) expect(clockMinutes(value)).toBeNull();
expect(noteMinutes('14:30 dinner')).toBe(870);
expect(noteMinutes('dinner at 14:30')).toBeNull();
expect(clockText(545)).toBe('09:05');
});
it('FE-AI-HINT-018: reads the weekday from the date alone, Monday first', () => {
expect(weekdayMondayFirst('2026-06-15')).toBe(0);
expect(weekdayMondayFirst(`${FRIDAY}T23:30:00`)).toBe(4);
expect(weekdayMondayFirst('2026-06-14')).toBe(6);
expect(weekdayMondayFirst('soon')).toBeNull();
});
it('FE-AI-HINT-019: spans stops and bookings, and overlaps them', () => {
expect(stopSpan(stop(1, 1, { time: '10:00', end: '09:00', duration: 30 }))).toEqual({ start: 600, end: 630 });
expect(stopSpan(stop(1, 1, { time: '23:30', duration: 90 }))).toEqual({ start: 1410, end: 1440 });
expect(stopSpan(stop(1, 1))).toBeNull();
const overnight = booking({ reservation_time: `${FRIDAY}T22:00`, end_day_id: 2 });
expect(bookingSpan(overnight, FRIDAY)).toEqual({ start: 1320, end: 1440 });
expect(bookingSpan(booking({ reservation_time: '21:00' }), FRIDAY)).toEqual({ start: 1260, end: null });
expect(bookingSpan(overnight, '2026-06-13')).toBeNull();
expect(spansOverlap({ start: 600, end: null }, { start: 540, end: 660 })).toBe(true);
expect(spansOverlap({ start: 540, end: 660 }, { start: 660, end: null })).toBe(false);
});
});
@@ -0,0 +1,484 @@
import { type AssistantFocus, parsePlaceHours, type PlaceOpeningHours, reservationTypeInfo } from '@trek/shared';
import type {
Accommodation,
Assignment,
AssignmentsMap,
Day,
DayNotesMap,
DistanceUnit,
Place,
Reservation,
} from '../../types';
import { formatClockTime, splitReservationDateTime } from '../../utils/formatters';
import { haversineKm } from '../../utils/geo';
import { formatDistance } from '../../utils/units';
import { dayBookings } from '../Planner/dayDetailModel';
import {
bookingSpan,
clockMinutes,
clockText,
type DaySpan,
noteMinutes,
spanKey,
spansOverlap,
spanTouches,
stopSpan,
weekdayMondayFirst,
} from './assistantHintsTime';
/**
* The plan hints the empty chat offers: things in the trip worth a second
* look, worked out from the data the client already holds. No model and no
* request is involved, so they cost nothing and appear at once. Each hint
* carries a short line, the question a tap sends, and the day or places that
* question is about.
*
* A false alarm costs more than a missed one. Only rules on hard data (times,
* stored opening hours) warn; whatever rests on an estimate is a quiet note.
*/
/** Free time between two stops that counts as a gap. */
export const GAP_MIN_MINUTES = 180;
/** Straight-line distance between two stops that counts as far, by how the leg is travelled. */
export const LONG_LEG_KM = { walking: 4, cycling: 20, other: 150 } as const;
/** Rough speed of a leg, to tell whether the time between two stops is enough. */
const LEG_SPEED_KMH = { walking: 4.5, cycling: 15, other: 50 } as const;
/** How much longer the way is than the straight line. */
const DETOUR_FACTOR = 1.3;
/** How far the estimate may run past the time available before it is worth a word. */
const TIGHT_LEG_SLACK_MINUTES = 10;
export type HintRule =
'overlap' | 'closed' | 'outsideHours' | 'tightLeg' | 'gap' | 'longLeg' | 'noStay' | 'unplacedBooking';
const RULE_ORDER: readonly HintRule[] = [
'overlap',
'closed',
'outsideHours',
'tightLeg',
'gap',
'longLeg',
'noStay',
'unplacedBooking',
];
const WARNS: ReadonlySet<HintRule> = new Set(['overlap', 'closed', 'outsideHours', 'tightLeg']);
/** Bookings that keep the traveller busy at their time, so a stop at the same time collides with them. */
const OCCUPYING_TYPES: ReadonlySet<string> = new Set(['restaurant', 'event', 'tour', 'activity']);
export interface AssistantHint {
/** Stable while the situation stays the same; a changed time or stop makes a new hint. */
id: string;
rule: HintRule;
level: 'warn' | 'info';
/** Position of the day in the trip, for the order; null for the trip as a whole. */
dayIndex: number | null;
/** The day the line names as {day}; the caller words it. */
dayId: number | null;
/** What the question is about, set on the chat before it is sent. */
focus: AssistantFocus | null;
textKey: string;
promptKey: string;
params: Record<string, string | number>;
}
export interface TripHintsInput {
/** The user's calendar date, YYYY-MM-DD; earlier days are left alone. */
today: string;
days: Day[];
assignments: AssignmentsMap;
places: Place[];
reservations: Reservation[];
dayNotes: DayNotesMap;
/** The trip's stays; null while they are not known, which keeps the night rule quiet. */
accommodations: Accommodation[] | null;
distanceUnit: DistanceUnit;
is12h: boolean;
}
interface Stop {
assignment: Assignment;
placeId: number;
name: string;
span: DaySpan | null;
hours: PlaceOpeningHours | null;
}
interface TimedBooking {
reservation: Reservation;
span: DaySpan;
}
interface PlannedDay {
day: Day;
index: number;
stops: Stop[];
/** The day's bookings with a time, the ones that occupy the traveller. */
bookings: TimedBooking[];
hasCarrier: boolean;
noteTimes: number[];
}
interface Ctx {
input: TripHintsInput;
clock: (minutes: number) => string;
}
function isLive(r: Reservation): boolean {
return r.status !== 'cancelled' && r.ingest_state !== 'staged';
}
function isCarrier(r: Reservation): boolean {
return reservationTypeInfo(r.type)?.isCarrier === true;
}
function hint(
rule: HintRule,
key: string,
at: { day: Day; index: number } | null,
fields: Pick<AssistantHint, 'focus' | 'textKey' | 'promptKey' | 'params'>
): AssistantHint {
return {
id: `${rule}:${at?.day.id ?? 'trip'}:${key}`,
rule,
level: WARNS.has(rule) ? 'warn' : 'info',
dayIndex: at?.index ?? null,
dayId: at?.day.id ?? null,
...fields,
};
}
function planDay(day: Day, index: number, input: TripHintsInput, placesById: Map<number, Place>): PlannedDay {
const all = (input.assignments[String(day.id)] ?? []).slice().sort((a, b) => a.order_index - b.order_index);
const toStop = (a: Assignment): Stop => ({
assignment: a,
placeId: a.place_id,
name: a.place.name,
span: stopSpan(a),
hours: parsePlaceHours(placesById.get(a.place_id)?.opening_hours),
});
// A stay's own stop is the overnight block, not a visit.
const stops = all.filter((a) => a.accommodation_id == null).map(toStop);
const booked = dayBookings(day.id, all, input.reservations.filter(isLive));
const bookings: TimedBooking[] = [];
for (const reservation of booked) {
if (!isCarrier(reservation) && !OCCUPYING_TYPES.has(reservation.type)) continue;
const span = bookingSpan(reservation, day.date);
if (span) bookings.push({ reservation, span });
}
const noteTimes = (input.dayNotes[String(day.id)] ?? [])
.map((n) => noteMinutes(n.time))
.filter((m): m is number => m !== null);
return { day, index, stops, bookings, hasCarrier: booked.some(isCarrier), noteTimes };
}
interface Slot {
key: string;
name: string;
span: DaySpan;
placeId: number | null;
assignmentId: number | null;
isStop: boolean;
}
/** A booking hung on a stop, or made for its place, is that stop: the two cannot collide. */
function sameVisit(a: Slot, b: Slot): boolean {
return (
(a.assignmentId !== null && a.assignmentId === b.assignmentId) || (a.placeId !== null && a.placeId === b.placeId)
);
}
function overlapHint(pd: PlannedDay): AssistantHint | null {
const slots: Slot[] = [];
for (const s of pd.stops) {
if (s.span) {
slots.push({
key: `a${s.assignment.id}`,
name: s.name,
span: s.span,
placeId: s.placeId,
assignmentId: s.assignment.id,
isStop: true,
});
}
}
for (const b of pd.bookings) {
slots.push({
key: `r${b.reservation.id}`,
name: b.reservation.title,
span: b.span,
placeId: b.reservation.place_id ?? null,
assignmentId: b.reservation.assignment_id ?? null,
isStop: false,
});
}
slots.sort((a, b) => a.span.start - b.span.start);
for (let i = 0; i < slots.length; i++) {
for (let j = i + 1; j < slots.length; j++) {
const [a, b] = [slots[i], slots[j]];
if (sameVisit(a, b) || !spansOverlap(a.span, b.span)) continue;
const placeIds = [a, b].filter((s) => s.isStop && s.placeId !== null).map((s) => s.placeId as number);
return hint('overlap', `${a.key}@${spanKey(a.span)},${b.key}@${spanKey(b.span)}`, pd, {
focus: placeIds.length > 0 ? { dayId: pd.day.id, placeIds } : { dayId: pd.day.id },
textKey: 'assistant.hint.overlap',
promptKey: 'assistant.hint.overlap.prompt',
params: { a: a.name, b: b.name },
});
}
}
return null;
}
function hoursHint(pd: PlannedDay, stop: Stop, weekday: number, ctx: Ctx): AssistantHint | null {
const hours = stop.hours?.[weekday];
if (!hours) return null;
const focus = { dayId: pd.day.id, placeIds: [stop.placeId] };
if (hours.closed) {
return hint('closed', `p${stop.placeId}`, pd, {
focus,
textKey: 'assistant.hint.closed',
promptKey: 'assistant.hint.closed.prompt',
params: { place: stop.name },
});
}
const open = clockMinutes(hours.open);
const close = clockMinutes(hours.close);
// Hours past midnight are left alone: which day they belong to is a guess.
if (!stop.span || open === null || close === null || close <= open) return null;
const { start } = stop.span;
const end = stop.span.end ?? start;
const key = `p${stop.placeId}@${spanKey(stop.span)}`;
if (start < open) {
return hint('outsideHours', key, pd, {
focus,
textKey: 'assistant.hint.opensLate',
promptKey: 'assistant.hint.outsideHours.prompt',
params: { place: stop.name, time: ctx.clock(open) },
});
}
if (end > close || start >= close) {
return hint('outsideHours', key, pd, {
focus,
textKey: 'assistant.hint.closesEarly',
promptKey: 'assistant.hint.outsideHours.prompt',
params: { place: stop.name, time: ctx.clock(close) },
});
}
return null;
}
function hoursHints(pd: PlannedDay, ctx: Ctx): AssistantHint[] {
const weekday = weekdayMondayFirst(pd.day.date);
if (weekday === null) return [];
return pd.stops.map((s) => hoursHint(pd, s, weekday, ctx)).filter((h): h is AssistantHint => h !== null);
}
/** Whether a booking or a timed note already fills the free time. */
function gapFilled(pd: PlannedDay, from: number, to: number): boolean {
return pd.bookings.some((b) => spanTouches(b.span, from, to)) || pd.noteTimes.some((t) => t > from && t < to);
}
/**
* The first long stretch with nothing planned between two timed stops. A free
* morning or evening is not a gap, and a stop whose end is not known leaves
* the time after it unknown too.
*/
function gapHint(pd: PlannedDay, ctx: Ctx): AssistantHint | null {
const timed = pd.stops.filter((s) => s.span).sort((a, b) => (a.span as DaySpan).start - (b.span as DaySpan).start);
let reach: number | null = null;
for (let k = 0; k < timed.length; k++) {
const span = timed[k].span as DaySpan;
if (k > 0 && reach !== null && span.start - reach >= GAP_MIN_MINUTES && !gapFilled(pd, reach, span.start)) {
return hint('gap', `${reach}-${span.start}`, pd, {
focus: { dayId: pd.day.id },
textKey: 'assistant.hint.gap',
promptKey: 'assistant.hint.gap.prompt',
params: {
count: Math.floor((span.start - reach) / 60),
from: ctx.clock(reach),
to: ctx.clock(span.start),
},
});
}
if (span.end === null) reach = reach !== null && reach > span.start ? reach : null;
else reach = Math.max(reach ?? span.end, span.end);
}
return null;
}
type LegClass = keyof typeof LONG_LEG_KM;
function legClass(mode: string | null | undefined): LegClass {
if (!mode || mode === 'walking') return 'walking';
return mode === 'cycling' ? 'cycling' : 'other';
}
/** A stop on the day's route: placed on the map, and not one a road trip or a tour lays out by itself. */
function routable(s: Stop): boolean {
const a = s.assignment;
return (
a.place.lat != null && a.place.lng != null && !a.route_excluded && !a.place.stop_type && a.tour_place_id == null
);
}
function tightLegHint(pd: PlannedDay, from: Stop, to: Stop, km: number, cls: LegClass): AssistantHint | null {
const leaves = from.span?.end;
const arrives = to.span?.start;
if (leaves == null || arrives == null) return null;
const available = arrives - leaves;
const needed = ((km * DETOUR_FACTOR) / LEG_SPEED_KMH[cls]) * 60;
// Less than nothing is an overlap, which says so itself.
if (available < 0 || needed <= available + TIGHT_LEG_SLACK_MINUTES) return null;
return hint('tightLeg', `a${from.assignment.id}-a${to.assignment.id}@${leaves}-${arrives}:${cls}`, pd, {
focus: { dayId: pd.day.id, placeIds: [from.placeId, to.placeId] },
textKey: 'assistant.hint.tightLeg',
promptKey: 'assistant.hint.tightLeg.prompt',
params: { count: available, from: from.name, to: to.name },
});
}
/**
* The legs between consecutive stops, by straight line: one that is short of
* time, and one that is far for the way it is travelled. A day with a flight,
* train or ferry is left alone, since the distance is the point of it.
*/
function legHints(pd: PlannedDay, ctx: Ctx): AssistantHint[] {
if (pd.hasCarrier) return [];
const route = pd.stops.filter(routable);
let tight: AssistantHint | null = null;
let long: AssistantHint | null = null;
for (let k = 1; k < route.length; k++) {
const [from, to] = [route[k - 1], route[k]];
const cls = legClass(from.assignment.leg_transport_mode ?? pd.day.default_transport_mode);
const km = haversineKm(
{ lat: from.assignment.place.lat as number, lng: from.assignment.place.lng as number },
{ lat: to.assignment.place.lat as number, lng: to.assignment.place.lng as number }
);
const tooTight: AssistantHint | null = tight ? null : tightLegHint(pd, from, to, km, cls);
if (tooTight) {
tight = tooTight;
continue;
}
if (!long && km > LONG_LEG_KM[cls]) {
long = hint('longLeg', `a${from.assignment.id}-a${to.assignment.id}:${cls}`, pd, {
focus: { dayId: pd.day.id, placeIds: [from.placeId, to.placeId] },
textKey: 'assistant.hint.longLeg',
promptKey: 'assistant.hint.longLeg.prompt',
params: { from: from.name, to: to.name, distance: formatDistance(km, ctx.input.distanceUnit) },
});
}
}
return [tight, long].filter((h): h is AssistantHint => h !== null);
}
/** Marks the nights from the day at `start` up to the one before `end`, by their place in the trip. */
function cover(nights: boolean[], start: number | undefined, end: number | undefined): void {
if (start === undefined || end === undefined) return;
for (let i = Math.max(start, 0); i < Math.min(end, nights.length); i++) nights[i] = true;
}
/**
* Nights without a bed: the night after each day but the last, covered by a
* stay or by a ride through it (night train, ferry, flight). Nights in a row
* make one hint, and nights already past count as covered.
*/
function noStayHints(input: TripHintsInput): AssistantHint[] {
const { days, accommodations } = input;
if (!accommodations || days.length < 2) return [];
const indexOf = new Map(days.map((d, i) => [d.id, i]));
const covered = days.slice(0, -1).map((d) => !!d.date && d.date < input.today);
for (const a of accommodations) cover(covered, indexOf.get(a.start_day_id), indexOf.get(a.end_day_id));
for (const r of input.reservations) {
if (!isLive(r) || !isCarrier(r) || r.day_id == null || r.end_day_id == null) continue;
cover(covered, indexOf.get(r.day_id), indexOf.get(r.end_day_id));
}
const hints: AssistantHint[] = [];
for (let i = 0; i < covered.length; i++) {
if (covered[i]) continue;
let nights = 1;
while (i + nights < covered.length && !covered[i + nights]) nights++;
const at = { day: days[i], index: i };
const night = hint('noStay', String(nights), at, {
focus: { dayId: at.day.id },
textKey: 'assistant.hint.noStay',
promptKey: 'assistant.hint.noStay.prompt',
params: { count: nights },
});
hints.push(night);
i += nights - 1;
}
return hints;
}
/**
* Bookings that hang on no day, which the day view therefore never shows. One
* names itself, and its day when its date is a day of the trip; several are
* counted. A booking already behind the user is left alone.
*/
function unplacedHint(input: TripHintsInput): AssistantHint | null {
const loose = input.reservations
.filter((r) => isLive(r) && r.type !== 'hotel' && r.day_id == null && r.assignment_id == null)
.map((r) => ({ r, date: splitReservationDateTime(r.reservation_time).date }))
.filter(({ date }) => !date || date >= input.today);
if (loose.length === 0) return null;
const key = loose
.map(({ r }) => r.id)
.sort((a, b) => a - b)
.join(',');
const fields = { promptKey: 'assistant.hint.unplacedBooking.prompt' };
if (loose.length > 1) {
return hint('unplacedBooking', key, null, {
...fields,
focus: null,
textKey: 'assistant.hint.unplacedBookings',
params: { count: loose.length },
});
}
const { r, date } = loose[0];
const index = date ? input.days.findIndex((d) => d.date === date) : -1;
if (index < 0) {
return hint('unplacedBooking', key, null, {
...fields,
focus: null,
textKey: 'assistant.hint.unplacedBooking',
params: { title: r.title },
});
}
const at = { day: input.days[index], index };
return hint('unplacedBooking', key, at, {
...fields,
focus: { dayId: at.day.id },
textKey: 'assistant.hint.unplacedBookingDay',
params: { title: r.title },
});
}
function byWeight(a: AssistantHint, b: AssistantHint): number {
if (a.level !== b.level) return a.level === 'warn' ? -1 : 1;
const ai = a.dayIndex ?? Number.MAX_SAFE_INTEGER;
const bi = b.dayIndex ?? Number.MAX_SAFE_INTEGER;
if (ai !== bi) return ai - bi;
return RULE_ORDER.indexOf(a.rule) - RULE_ORDER.indexOf(b.rule);
}
/** Every hint for the trip, warnings first, then by day and rule. */
export function tripHints(input: TripHintsInput): AssistantHint[] {
const ctx: Ctx = { input, clock: (m) => formatClockTime(clockText(m), input.is12h) };
const placesById = new Map(input.places.map((p) => [p.id, p]));
const hints: AssistantHint[] = [];
input.days.forEach((day, index) => {
if (day.date && day.date < input.today) return;
const pd = planDay(day, index, input, placesById);
const overlap = overlapHint(pd);
const gap = gapHint(pd, ctx);
if (overlap) hints.push(overlap);
hints.push(...hoursHints(pd, ctx), ...legHints(pd, ctx));
if (gap) hints.push(gap);
});
hints.push(...noStayHints(input));
const unplaced = unplacedHint(input);
if (unplaced) hints.push(unplaced);
return hints.sort(byWeight);
}
@@ -0,0 +1,104 @@
import type { Assignment, Reservation } from '../../types';
import { splitReservationDateTime } from '../../utils/formatters';
/**
* The clock arithmetic behind the plan hints: times of day as minutes since
* midnight, and the stretch of the day a stop or a booking takes. A value
* that is not a plain HH:MM reads as no time at all, so a rule that needs it
* stays quiet rather than guessing.
*/
const MINUTES_PER_DAY = 24 * 60;
/** A stretch of one day in minutes since midnight; `end` is null when only the start is known. */
export interface DaySpan {
start: number;
end: number | null;
}
function minutesOf(match: RegExpExecArray | null): number | null {
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
return hours < 24 && minutes < 60 ? hours * 60 + minutes : null;
}
/** "09:30" as 570; anything that is not HH:MM (or H:MM) is null. */
export function clockMinutes(value: string | null | undefined): number | null {
return minutesOf(/^(\d{1,2}):(\d{2})$/.exec(value?.trim() ?? ''));
}
/** The time a day note starts with, if it starts with one: the note is free text. */
export function noteMinutes(value: string | null | undefined): number | null {
return minutesOf(/^(\d{1,2}):(\d{2})(?!\d)/.exec(value?.trim() ?? ''));
}
/** 570 as "09:30". */
export function clockText(minutes: number): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
}
/**
* The weekday of a calendar date, Monday first like stored opening hours.
* Read in UTC, so the zone of the device cannot move the date a day.
*/
export function weekdayMondayFirst(iso: string | null | undefined): number | null {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso ?? '');
if (!match) return null;
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
return Number.isNaN(date.getTime()) ? null : (date.getUTCDay() + 6) % 7;
}
/**
* When a stop takes place: its own time on this day before the place's, an
* end before a duration. An end at or before the start says nothing, and a
* duration never runs past midnight.
*/
export function stopSpan(assignment: Assignment): DaySpan | null {
const start = clockMinutes(assignment.assignment_time ?? assignment.place.place_time);
if (start === null) return null;
const end = clockMinutes(assignment.assignment_end_time ?? assignment.place.end_time);
if (end !== null && end > start) return { start, end };
const duration = assignment.place.duration_minutes ?? 0;
return { start, end: duration > 0 ? Math.min(start + duration, MINUTES_PER_DAY) : null };
}
/**
* When a booking takes place on its day. A time written for another date than
* the day's says nothing about it, and a booking that ends on a later day runs
* to midnight.
*/
export function bookingSpan(reservation: Reservation, dayDate: string | null | undefined): DaySpan | null {
const begins = splitReservationDateTime(reservation.reservation_time);
if (begins.date && dayDate && begins.date !== dayDate) return null;
const start = clockMinutes(begins.time);
if (start === null) return null;
if (reservation.end_day_id != null && reservation.end_day_id !== reservation.day_id) {
return { start, end: MINUTES_PER_DAY };
}
const end = clockMinutes(splitReservationDateTime(reservation.reservation_end_time).time);
return { start, end: end !== null && end > start ? end : null };
}
/**
* Whether two stretches share time. Two stretches overlap when each starts
* before the other ends; a bare start overlaps only when it lies truly inside
* a stretch, and two bare starts never do.
*/
export function spansOverlap(a: DaySpan, b: DaySpan): boolean {
if (a.end !== null && b.end !== null) return a.start < b.end && b.start < a.end;
if (a.end !== null) return a.start < b.start && b.start < a.end;
if (b.end !== null) return b.start < a.start && a.start < b.end;
return false;
}
/** Whether a stretch, or a bare start, falls into the open window from `from` to `to`. */
export function spanTouches(span: DaySpan, from: number, to: number): boolean {
return span.start < to && (span.end ?? span.start) > from;
}
/** A span as it goes into a hint's id, so a hint about other times is a new hint. */
export function spanKey(span: DaySpan): string {
return `${span.start}-${span.end ?? ''}`;
}
@@ -0,0 +1,101 @@
/**
* FE-AI-MENTION-MODEL-001 to FE-AI-MENTION-MODEL-008
*
* What `@` offers and writes, without React: the entries per kind, the search
* across them, the mention behind a pick, and the text that carries it.
*/
import type { TriggerItem } from '@assistant-ui/react';
import { describe, expect, it } from 'vitest';
import { buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import {
mentionGroups,
mentionLabel,
mentionOf,
searchMentions,
splitMentions,
withMention,
} from './assistantMentionModel';
function t(key: string, params?: Record<string, string | number>): string {
return key === 'dayplan.dayN' ? `Day ${params?.n}` : key;
}
const SOURCES = {
days: [buildDay({ id: 1, title: 'Arrival', date: '2026-11-02' }), buildDay({ id: 2, title: null, date: null })],
places: [
buildPlace({ id: 10, name: 'Louvre' }),
buildPlace({ id: 11, name: 'Musée du Louvre shop' }),
buildPlace({ id: 12, name: 'Bouillon Pigalle' }),
buildPlace({ id: 13, name: ' ' }),
],
reservations: [buildReservation({ id: 20, title: 'Louvre tickets', day_id: 1 })],
};
describe('assistantMentionModel', () => {
it('FE-AI-MENTION-MODEL-001: lists days by their day-list name, places by name, bookings with their day', () => {
const groups = mentionGroups(SOURCES, t, 'en');
expect(groups.day.map((i) => i.label)).toEqual(['Arrival', 'Day 2']);
// A titled day adds its date; an untitled one says nothing more.
expect(groups.day[0].description).toBeTruthy();
expect(groups.day[1].description).toBeUndefined();
// A place without a name has nothing to write into the text and is left out.
expect(groups.place.map((i) => i.id)).toEqual(['place:10', 'place:11', 'place:12']);
expect(groups.reservation[0]).toMatchObject({
label: 'Louvre tickets',
description: 'Arrival',
type: 'reservation',
});
});
it('FE-AI-MENTION-MODEL-002: search ranks a label that starts with the query before a word inside one', () => {
const groups = mentionGroups(SOURCES, t, 'en');
expect(searchMentions(groups, 'louv').map((i) => i.label)).toEqual([
'Louvre',
'Louvre tickets',
'Musée du Louvre shop',
]);
expect(searchMentions(groups, 'llon').map((i) => i.label)).toEqual(['Bouillon Pigalle']);
expect(searchMentions(groups, 'zzz')).toEqual([]);
});
it('FE-AI-MENTION-MODEL-003: search shows at most eight hits', () => {
const places = Array.from({ length: 12 }, (_, i) => buildPlace({ id: 100 + i, name: `Café ${i}` }));
expect(searchMentions(mentionGroups({ ...SOURCES, places }, t, 'en'), 'caf')).toHaveLength(8);
});
it('FE-AI-MENTION-MODEL-004: a pick reads back as its mention, anything else as nothing', () => {
const [louvre] = mentionGroups(SOURCES, t, 'en').place;
expect(mentionOf(louvre)).toEqual({ kind: 'place', id: 10, label: 'Louvre' });
const foreign: TriggerItem = { id: 'x', type: 'command', label: 'Run' };
expect(mentionOf(foreign)).toBeNull();
expect(mentionOf({ ...louvre, metadata: { id: 'ten' } })).toBeNull();
});
it('FE-AI-MENTION-MODEL-005: labels are one trimmed line the server keeps whole', () => {
expect(mentionLabel(' Le Train\nBleu ')).toBe('Le Train Bleu');
expect(mentionLabel('x'.repeat(200))).toHaveLength(120);
});
it('FE-AI-MENTION-MODEL-006: writing @label keeps the rest of the text, once', () => {
expect(withMention('', 'Louvre')).toBe('@Louvre ');
expect(withMention('Lunch near', 'Louvre')).toBe('Lunch near @Louvre ');
expect(withMention('Lunch near ', 'Louvre')).toBe('Lunch near @Louvre ');
expect(withMention('Near @Louvre ', 'Louvre')).toBe('Near @Louvre ');
// A longer label that starts the same is another mention.
expect(withMention('@Romeo ', 'Rome')).toBe('@Romeo @Rome ');
});
it('FE-AI-MENTION-MODEL-008: a sent question splits into text and its mentions, the longest label first', () => {
expect(splitMentions('Is @Louvre tickets still valid at @Louvre?', ['Louvre', 'Louvre tickets'])).toEqual([
{ text: 'Is ', at: 0, mention: false },
{ text: '@Louvre tickets', at: 3, mention: true },
{ text: ' still valid at ', at: 18, mention: false },
{ text: '@Louvre', at: 34, mention: true },
{ text: '?', at: 41, mention: false },
]);
expect(splitMentions('mail me @ home @Lou', ['Louvre'])).toEqual([
{ text: 'mail me @ home @Lou', at: 0, mention: false },
]);
});
});
@@ -0,0 +1,159 @@
import type { DirectiveFormatter, TriggerItem } from '@assistant-ui/react';
import { ASSISTANT_MENTION_KINDS, type AssistantMention } from '@trek/shared';
import type { AssistantMentionItem } from '../../store/assistantStore';
import type { Day, Place, Reservation } from '../../types';
import { dayDate, dayLabel } from '../../utils/dayLabel';
/**
* What `@` in the composer offers and what it writes: the trip's days, places
* and bookings as entries of the popover, `@label` in the text for a pick, and
* the way back from that text to the mentions a question still carries.
*/
export type MentionKind = AssistantMention['kind'];
/** The heading of each list `@` opens. */
export const MENTION_KINDS: Record<MentionKind, { labelKey: string }> = {
day: { labelKey: 'assistant.mention.days' },
place: { labelKey: 'assistant.mention.places' },
reservation: { labelKey: 'assistant.mention.bookings' },
};
type Translate = (key: string, params?: Record<string, string | number>) => string;
/** The longest label the server keeps for a mention. */
const LABEL_MAX = 120;
/** Hits shown while typing after `@`; the list stays one short glance. */
const SEARCH_MAX = 8;
interface MentionSources {
days: readonly Day[];
places: readonly Place[];
reservations: readonly Reservation[];
}
type MentionGroups = Record<MentionKind, TriggerItem[]>;
/** A label as it goes into the text: one line, trimmed, never longer than the server keeps. */
export function mentionLabel(name: string): string {
return name.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX);
}
function entry(kind: MentionKind, id: number, name: string, description?: string | null): TriggerItem[] {
const label = mentionLabel(name);
if (!label) return [];
return [{ id: `${kind}:${id}`, type: kind, label, ...(description ? { description } : {}), metadata: { id } }];
}
/**
* The entries `@` lists, per kind. A day reads as it does in the day list; a
* titled day adds its date, and a booking the day it is on.
*/
export function mentionGroups(sources: MentionSources, t: Translate, locale: string): MentionGroups {
const dayNames = new Map(sources.days.map((d, i) => [d.id, dayLabel(d, i, t, locale)]));
return {
day: sources.days.flatMap((d) =>
entry('day', d.id, dayNames.get(d.id) ?? '', d.title && d.date ? dayDate(d.date, locale) : null)
),
place: sources.places.flatMap((p) => entry('place', p.id, p.name)),
reservation: sources.reservations.flatMap((r) =>
entry('reservation', r.id, r.title, r.day_id ? dayNames.get(r.day_id) : null)
),
};
}
/** 0 for a label that starts with the query, 1 for one with a word that does, 2 for the rest. */
function rankOf(label: string, at: number): number {
if (at === 0) return 0;
return /\s/u.test(label.charAt(at - 1)) ? 1 : 2;
}
/** The entries whose label holds the query, across all kinds, the best matches first. */
export function searchMentions(groups: MentionGroups, query: string): TriggerItem[] {
const q = query.toLocaleLowerCase();
const hits: { item: TriggerItem; rank: number }[] = [];
for (const kind of ASSISTANT_MENTION_KINDS) {
for (const item of groups[kind]) {
const label = item.label.toLocaleLowerCase();
const at = label.indexOf(q);
if (at >= 0) hits.push({ item, rank: rankOf(label, at) });
}
}
return hits
.sort((a, b) => a.rank - b.rank)
.slice(0, SEARCH_MAX)
.map((h) => h.item);
}
/** The mention a picked entry stands for; null for anything the popover did not build. */
export function mentionOf(item: TriggerItem): AssistantMentionItem | null {
const kind = ASSISTANT_MENTION_KINDS.find((k) => k === item.type);
const id = item.metadata?.id;
if (!kind || typeof id !== 'number') return null;
return { kind, id, label: item.label };
}
/** A pick goes into the text as `@label`; the text stays plain, what it names is kept in the store. */
export const MENTION_FORMATTER: DirectiveFormatter = {
serialize: (item) => `@${item.label}`,
parse: (text) => [{ kind: 'text', text }],
};
/** True when no letter or digit follows at `end`, so `@Rome` does not end inside `@Romeo`. */
function endsWord(text: string, end: number): boolean {
return !/[\p{L}\p{N}]/u.test(text.charAt(end));
}
/** Where `@label` stands in the text as a whole word. */
function tokenAt(text: string, label: string): number {
const token = `@${label}`;
for (let at = text.indexOf(token); at >= 0; at = text.indexOf(token, at + 1)) {
if (endsWord(text, at + token.length)) return at;
}
return -1;
}
function hasMention(text: string, label: string): boolean {
return tokenAt(text, label) >= 0;
}
/** The text with `@label ` written at its end, the way a pick from the popover leaves it. */
export function withMention(text: string, label: string): string {
if (hasMention(text, label)) return text;
const gap = text === '' || /\s$/u.test(text) ? '' : ' ';
return `${text}${gap}@${label} `;
}
interface MentionSegment {
text: string;
/** Where the segment starts in the text. */
at: number;
/** True for an `@label` the question named a day, place or booking with. */
mention: boolean;
}
/** A sent question cut into plain text and its `@label` mentions; the longest label wins a tie. */
export function splitMentions(text: string, labels: readonly string[]): MentionSegment[] {
const tokens = [...new Set(labels)]
.filter(Boolean)
.map((l) => `@${l}`)
.sort((a, b) => b.length - a.length);
const out: MentionSegment[] = [];
let start = 0;
let i = 0;
while (i < text.length) {
const token =
text[i] === '@' ? tokens.find((tk) => text.startsWith(tk, i) && endsWord(text, i + tk.length)) : undefined;
if (token) {
if (i > start) out.push({ text: text.slice(start, i), at: start, mention: false });
out.push({ text: token, at: i, mention: true });
i += token.length;
start = i;
} else {
i += 1;
}
}
if (start < text.length) out.push({ text: text.slice(start), at: start, mention: false });
return out;
}
@@ -0,0 +1,179 @@
/**
* FE-AI-MODEL-001 to FE-AI-MODEL-010
*
* The pure half of the assistant's views: how a stored message reaches
* @assistant-ui, what each error offers, how a reading tool is named while it
* runs and once it says where it looks, and which questions the empty chat
* suggests.
*/
import { ASSISTANT_ERROR_CODES, type AssistantMessage } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { errorView, liveStepText, suggestionsFor, toolStepKey, toThreadMessage, tripPhase } from './assistantModel';
const AT = '2026-10-21T08:00:00.000Z';
describe('toThreadMessage', () => {
it('FE-AI-MODEL-001: passes text, tool calls with their approval and TREK data parts through, and drops empty text', () => {
const message: AssistantMessage = {
id: 'm1',
role: 'assistant',
status: 'awaiting_approval',
createdAt: AT,
parts: [
{ type: 'text', text: '' },
{ type: 'text', text: 'Adding it.' },
{
type: 'tool-call',
toolCallId: 'c1',
toolName: 'create_todo',
args: { name: 'Tickets' },
approval: { id: 'c1' },
},
{ type: 'tool-call', toolCallId: 'r1', toolName: 'list_todos', args: {}, result: [], isError: false },
{
type: 'data-trek-applied',
data: { toolCallId: 'c0', entity: 'todo', ids: [1], dayId: null, undoUntil: null },
},
],
};
const like = toThreadMessage(message);
expect(like).toEqual({
id: 'm1',
role: 'assistant',
createdAt: new Date(AT),
content: [
{ type: 'text', text: 'Adding it.' },
{
type: 'tool-call',
toolCallId: 'c1',
toolName: 'create_todo',
args: { name: 'Tickets' },
approval: { id: 'c1' },
},
{ type: 'tool-call', toolCallId: 'r1', toolName: 'list_todos', args: {}, result: [], isError: false },
{
type: 'data-trek-applied',
data: { toolCallId: 'c0', entity: 'todo', ids: [1], dayId: null, undoUntil: null },
},
],
});
// Complete and waiting messages leave the status to the runtime.
expect(like).not.toHaveProperty('status');
});
it('FE-AI-MODEL-002: a stopped or failed message says so', () => {
const base: AssistantMessage = { id: 'm', role: 'assistant', parts: [], status: 'aborted', createdAt: AT };
expect(toThreadMessage(base).status).toEqual({ type: 'incomplete', reason: 'cancelled' });
expect(toThreadMessage({ ...base, status: 'error' }).status).toEqual({ type: 'incomplete', reason: 'error' });
});
});
describe('errorView', () => {
it('FE-AI-MODEL-003: every error code has a line, and at most one action', () => {
for (const code of ASSISTANT_ERROR_CODES) {
const view = errorView(code, { canConnectOwn: true });
expect(view.messageKey).toMatch(/^assistant\.error\./);
if (view.action) expect(view.action.labelKey).toMatch(/^assistant\.errorAction\./);
}
expect(errorView('busy', { canConnectOwn: true }).action?.kind).toBe('continueHere');
expect(errorView('provider_unavailable', { canConnectOwn: true })).toMatchObject({
messageKey: 'assistant.error.providerDown',
action: { kind: 'retry' },
});
expect(errorView('usage_limit', { canConnectOwn: true }).action?.kind).toBe('openUsage');
expect(errorView('offline', { canConnectOwn: true }).action).toBeNull();
});
it('FE-AI-MODEL-004: the daily limit offers an own provider only where the policy allows one', () => {
expect(errorView('daily_limit', { canConnectOwn: true }).action).toEqual({
kind: 'openSettings',
labelKey: 'assistant.errorAction.connectOwn',
});
expect(errorView('daily_limit', { canConnectOwn: false }).action).toBeNull();
});
it('FE-AI-MODEL-008: a provider limit names the provider when known, else keeps the general line', () => {
expect(errorView('rate_limited', { canConnectOwn: true, provider: 'Mistral' }).messageKey).toBe(
'assistant.error.providerLimit'
);
expect(errorView('usage_limit', { canConnectOwn: true, provider: 'ChatGPT' })).toMatchObject({
messageKey: 'assistant.error.providerLimit',
action: { kind: 'openUsage' },
});
expect(errorView('rate_limited', { canConnectOwn: true, provider: '' }).messageKey).toBe(
'assistant.error.rateLimited'
);
expect(errorView('usage_limit', { canConnectOwn: true }).messageKey).toBe('assistant.error.usageLimit');
expect(errorView('generic', { canConnectOwn: true, provider: 'Mistral' }).messageKey).toBe(
'assistant.error.generic'
);
// TREK's own limit never blames the provider.
expect(errorView('too_many_requests', { canConnectOwn: true, provider: 'OpenRouter' }).messageKey).toBe(
'assistant.error.rateLimited'
);
});
it('FE-AI-MODEL-009: waiting changes ask for a decision first, with nothing to press', () => {
expect(errorView('decide_first', { canConnectOwn: true })).toEqual({
messageKey: 'assistant.error.decideFirst',
action: null,
});
});
});
describe('toolStepKey', () => {
it('FE-AI-MODEL-005: names reading tools by what they do, and anything else as working', () => {
expect(toolStepKey('get_trip_summary')).toBe('assistant.step.reading');
expect(toolStepKey('search_nearby_places')).toBe('assistant.step.nearby');
expect(toolStepKey('get_settlement_summary')).toBe('assistant.step.costs');
expect(toolStepKey('create_place')).toBe('assistant.step.working');
expect(toolStepKey('constructor')).toBe('assistant.step.working');
});
it('FE-AI-MODEL-010: a running step that names a place or a query says it, and falls back to its name', () => {
expect(liveStepText('search_nearby_places', 'Trastevere')).toEqual({
key: 'assistant.step.nearbyAt',
params: { place: 'Trastevere' },
});
expect(liveStepText('search_pois', 'Shibuya')).toEqual({
key: 'assistant.step.nearbyAt',
params: { place: 'Shibuya' },
});
expect(liveStepText('search_place', 'gelato')).toEqual({
key: 'assistant.step.searchingFor',
params: { query: 'gelato' },
});
expect(liveStepText('get_weather', 'Kyoto')).toEqual({
key: 'assistant.step.weatherAt',
params: { place: 'Kyoto' },
});
expect(liveStepText('search_nearby_places', null)).toEqual({ key: 'assistant.step.nearby' });
expect(liveStepText('list_places', 'Kyoto')).toEqual({ key: 'assistant.step.reading' });
});
});
describe('suggestions', () => {
it('FE-AI-MODEL-006: the phase follows the dates and whether anything is planned', () => {
const today = '2026-06-12';
expect(tripPhase({ startDate: null, endDate: null, hasPlaces: false, today })).toBe('empty');
expect(tripPhase({ startDate: '2026-07-01', endDate: '2026-07-05', hasPlaces: true, today })).toBe('upcoming');
expect(tripPhase({ startDate: '2026-06-10', endDate: '2026-06-14', hasPlaces: false, today })).toBe('running');
expect(tripPhase({ startDate: '2026-06-12', endDate: null, hasPlaces: true, today })).toBe('running');
expect(tripPhase({ startDate: '2026-06-01', endDate: '2026-06-11', hasPlaces: true, today })).toBe('past');
});
it('FE-AI-MODEL-007: focus comes first, at most four, and nothing that writes for someone who may only read', () => {
const focused = suggestionsFor({ phase: 'upcoming', canEdit: true, place: 'Kinkaku-ji', day: 'Fri 12 Jun' });
expect(focused.map((s) => s.id)).toEqual(['nearPlace', 'placeTips', 'planDay', 'dayTooFull']);
expect(focused[0]?.params).toEqual({ place: 'Kinkaku-ji' });
const readOnly = suggestionsFor({ phase: 'empty', canEdit: false, day: 'Fri 12 Jun' });
expect(readOnly.map((s) => s.id)).toEqual(['dayTooFull', 'mustSee', 'weather']);
expect(readOnly.every((s) => !s.writes)).toBe(true);
expect(suggestionsFor({ phase: 'past', canEdit: true }).map((s) => s.labelKey)).toEqual([
'assistant.suggest.settleUp',
'assistant.suggest.recap',
]);
});
});
@@ -0,0 +1,283 @@
import type { ThreadMessageLike } from '@assistant-ui/react';
import type { AiStatusResponse, AssistantErrorCode, AssistantMessage, AssistantMessagePart } from '@trek/shared';
/**
* Pure helpers the assistant's views share, desktop window and phone screen
* alike: the bridge from stored messages to @assistant-ui, what an error says
* and offers, how a reading tool is named while it runs, and which questions
* the empty chat suggests. No React, and only a type from @assistant-ui, so
* importing this keeps the chat runtime out of the chunk.
*/
type LikePart = Exclude<ThreadMessageLike['content'], string>[number];
const STATUS: Partial<Record<AssistantMessage['status'], ThreadMessageLike['status']>> = {
aborted: { type: 'incomplete', reason: 'cancelled' },
error: { type: 'incomplete', reason: 'error' },
};
function toPart(part: AssistantMessagePart): LikePart | null {
if (part.type === 'text') return part.text ? { type: 'text', text: part.text } : null;
// An attached file is drawn from the stored message; the runtime takes it as one more data part.
if (part.type === 'attachment') {
return { type: 'data-trek-attachment', data: { name: part.name, mime: part.mime, size: part.size } };
}
if (part.type !== 'tool-call') return part;
return {
type: 'tool-call',
toolCallId: part.toolCallId,
toolName: part.toolName,
args: part.args,
...(part.result !== undefined ? { result: part.result } : {}),
...(part.isError !== undefined ? { isError: part.isError } : {}),
...(part.approval ? { approval: part.approval } : {}),
} as LikePart;
}
/**
* A stored message as @assistant-ui reads it (the external store runtime's
* `convertMessage`). Complete and waiting messages carry no status, so the
* runtime derives it: running while the turn streams, waiting while a change
* is undecided.
*/
export function toThreadMessage(message: AssistantMessage): ThreadMessageLike {
const status = STATUS[message.status];
return {
id: message.id,
role: message.role,
createdAt: new Date(message.createdAt),
content: message.parts.map(toPart).filter((p): p is LikePart => p !== null),
...(status ? { status } : {}),
};
}
// ── Errors ──────────────────────────────────────────────────────────────────
/** What the one action under an error line does. */
export type AssistantErrorActionKind = 'retry' | 'continueHere' | 'continue' | 'openSettings' | 'openUsage';
interface AssistantErrorView {
messageKey: string;
action: { kind: AssistantErrorActionKind; labelKey: string } | null;
}
const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
not_connected: { messageKey: 'assistant.error.notConnected', action: null },
busy: {
messageKey: 'assistant.error.busy',
action: { kind: 'continueHere', labelKey: 'assistant.errorAction.continueHere' },
},
daily_limit: {
messageKey: 'assistant.error.dailyLimit',
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.connectOwn' },
},
rate_limited: { messageKey: 'assistant.error.rateLimited', action: null },
needs_reauth: {
messageKey: 'assistant.error.needsReauth',
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.signIn' },
},
usage_limit: {
messageKey: 'assistant.error.usageLimit',
action: { kind: 'openUsage', labelKey: 'assistant.errorAction.viewUsage' },
},
not_eligible: {
messageKey: 'assistant.error.notEligible',
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.otherProvider' },
},
invalid_key: {
messageKey: 'assistant.error.invalidKey',
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.replaceKey' },
},
provider_unavailable: {
messageKey: 'assistant.error.providerDown',
action: { kind: 'retry', labelKey: 'assistant.errorAction.retry' },
},
max_steps: {
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 },
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
generic: {
messageKey: 'assistant.error.generic',
action: { kind: 'retry', labelKey: 'assistant.errorAction.retry' },
},
};
/** The limits a provider sets; the line names the provider instead of guessing at its plan. */
const PROVIDER_LIMITS: ReadonlySet<AssistantErrorCode> = new Set(['rate_limited', 'usage_limit']);
/**
* The line an error shows and its one action. The daily limit only offers
* connecting an own provider where the policy allows one. A provider's limit
* says whose it is when the provider is known (`provider` is its name, empty
* when not), and keeps the general line otherwise.
*/
export function errorView(
code: AssistantErrorCode,
opts: { canConnectOwn: boolean; provider?: string }
): AssistantErrorView {
const view = ERRORS[code];
if (code === 'daily_limit' && !opts.canConnectOwn) return { ...view, action: null };
if (PROVIDER_LIMITS.has(code) && opts.provider) return { ...view, messageKey: 'assistant.error.providerLimit' };
return view;
}
/**
* The error the user's own connection shows before anything is sent, once it
* stopped working: a sign-in to renew or a key to replace. The window says so
* right away instead of waiting for the next question to fail.
*/
export function connectionErrorOf(status: AiStatusResponse | null): AssistantErrorCode | null {
if (!status?.own || status.effective?.source !== 'user') return null;
if (status.own.status === 'needs_reauth') return 'needs_reauth';
if (status.own.status === 'error') return 'invalid_key';
return null;
}
// ── Tool steps ──────────────────────────────────────────────────────────────
/** `liveKey` names the step with the place or query it is at, `param` is the word that fills it. */
interface StepLook {
stepKey: string;
live?: { liveKey: string; param: 'place' | 'query' };
}
const STEPS: Partial<Record<string, StepLook>> = {
get_trip_summary: { stepKey: 'assistant.step.reading' },
list_places: { stepKey: 'assistant.step.reading' },
search_place: {
stepKey: 'assistant.step.searching',
live: { liveKey: 'assistant.step.searchingFor', param: 'query' },
},
search_pois: { stepKey: 'assistant.step.searching', live: { liveKey: 'assistant.step.nearbyAt', param: 'place' } },
get_place_details: { stepKey: 'assistant.step.details' },
search_nearby_places: {
stepKey: 'assistant.step.nearby',
live: { liveKey: 'assistant.step.nearbyAt', param: 'place' },
},
list_categories: { stepKey: 'assistant.step.categories' },
get_weather: { stepKey: 'assistant.step.weather', live: { liveKey: 'assistant.step.weatherAt', param: 'place' } },
list_todos: { stepKey: 'assistant.step.todos' },
get_settlement_summary: { stepKey: 'assistant.step.costs' },
search_help: { stepKey: 'assistant.step.help' },
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
};
const WORKING = { stepKey: 'assistant.step.working' };
/** How a reading tool is named while it runs: "Reading your plan", never the tool's name. */
export function toolStepKey(toolName: string): string {
return STEPS[toolName]?.stepKey ?? WORKING.stepKey;
}
/**
* How a running step is named once it said where it looks: "Searching near
* Trastevere", "Looking for gelato". The general name when it named nothing
* or the step has no such line.
*/
export function liveStepText(toolName: string, label: string | null): { key: string; params?: Record<string, string> } {
const live = STEPS[toolName]?.live;
if (!live || !label) return { key: toolStepKey(toolName) };
return { key: live.liveKey, params: { [live.param]: label } };
}
// ── Weather ─────────────────────────────────────────────────────────────────
/** A temperature the server sends in °C, in the user's unit, rounded; never "-0". */
export function inUnit(celsius: number, fahrenheit: boolean): number {
return Math.round(fahrenheit ? (celsius * 9) / 5 + 32 : celsius) || 0;
}
// ── Suggestions ─────────────────────────────────────────────────────────────
type TripPhase = 'empty' | 'upcoming' | 'running' | 'past';
/** Where the trip stands on `today` (YYYY-MM-DD, in the user's zone). Dates missing read as not started. */
export function tripPhase(trip: {
startDate: string | null;
endDate: string | null;
hasPlaces: boolean;
today: string;
}): TripPhase {
const { startDate, endDate, today } = trip;
if (endDate && endDate < today) return 'past';
if (startDate && startDate <= today && (!endDate || today <= endDate)) return 'running';
return trip.hasPlaces ? 'upcoming' : 'empty';
}
interface AssistantSuggestion {
id: string;
labelKey: string;
params?: Record<string, string>;
/** Asks the assistant to change the trip, so it is left out for someone who may only read. */
writes?: boolean;
}
const PHASE_SUGGESTIONS: Record<TripPhase, AssistantSuggestion[]> = {
empty: [
{ id: 'planFirstDays', labelKey: 'assistant.suggest.planFirstDays', writes: true },
{ id: 'mustSee', labelKey: 'assistant.suggest.mustSee' },
{ id: 'packingList', labelKey: 'assistant.suggest.packingList', writes: true },
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
],
upcoming: [
{ id: 'emptyDays', labelKey: 'assistant.suggest.emptyDays' },
{ id: 'packingList', labelKey: 'assistant.suggest.packingList', writes: true },
{ id: 'rainPlan', labelKey: 'assistant.suggest.rainPlan' },
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
],
running: [
{ id: 'today', labelKey: 'assistant.suggest.today' },
{ id: 'eatNearby', labelKey: 'assistant.suggest.eatNearby' },
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
{ id: 'openTodos', labelKey: 'assistant.suggest.openTodos' },
],
past: [
{ id: 'settleUp', labelKey: 'assistant.suggest.settleUp' },
{ id: 'recap', labelKey: 'assistant.suggest.recap' },
],
};
const MAX_SUGGESTIONS = 4;
/**
* The questions the empty chat offers: about the place or day in focus first,
* then what fits the trip's phase. The label is also the prompt, so a tapped
* suggestion reads in the chat exactly as it was offered.
*/
export function suggestionsFor(input: {
phase: TripPhase;
canEdit: boolean;
/** The name of the place in focus. */
place?: string | null;
/** The label of the day in focus ("Fri 12 Jun"). */
day?: string | null;
}): AssistantSuggestion[] {
const focused: AssistantSuggestion[] = [];
if (input.place) {
focused.push(
{ id: 'nearPlace', labelKey: 'assistant.suggest.nearPlace', params: { place: input.place } },
{ id: 'placeTips', labelKey: 'assistant.suggest.placeTips', params: { place: input.place } }
);
}
if (input.day) {
focused.push(
{ id: 'planDay', labelKey: 'assistant.suggest.planDay', params: { day: input.day }, writes: true },
{ id: 'dayTooFull', labelKey: 'assistant.suggest.dayTooFull', params: { day: input.day } }
);
}
const picked: AssistantSuggestion[] = [];
for (const suggestion of [...focused, ...PHASE_SUGGESTIONS[input.phase]]) {
if (suggestion.writes && !input.canEdit) continue;
if (picked.some((p) => p.id === suggestion.id)) continue;
picked.push(suggestion);
}
return picked.slice(0, MAX_SUGGESTIONS);
}
@@ -0,0 +1,326 @@
/**
* FE-AI-PARTS-001 to FE-AI-PARTS-014
*
* How an answer is laid out: text, the steps the assistant looked up, the
* places and the weather they found, and one card per batch of proposed
* changes, each change in the state its approval and receipt put it in.
*/
import {
ASSISTANT_CHANGE_LABEL_KEYS,
type AssistantApplied,
type AssistantChangePreview,
type AssistantFoundPlace,
type AssistantMessage,
type AssistantMessagePart,
type AssistantWeatherDay,
} from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { CHANGE_LABELS, hasUndecided, messageBlocks, suggestedFromStrip, undoLeftMs } from './assistantParts';
const AT = '2026-10-21T08:00:00.000Z';
function message(parts: AssistantMessagePart[]): AssistantMessage {
return { id: 'm1', role: 'assistant', status: 'complete', createdAt: AT, parts };
}
function preview(toolCallId: string): AssistantChangePreview {
return {
toolCallId,
verbKey: 'addTodo',
subject: `Todo ${toolCallId}`,
rows: [],
dayId: null,
placeIds: [],
undoable: true,
};
}
function applied(toolCallId: string, undoUntil: string | null = null): AssistantApplied {
return { toolCallId, entity: 'todo', ids: [1], dayId: null, undoUntil };
}
const read = (id: string, result?: unknown): AssistantMessagePart => ({
type: 'tool-call',
toolCallId: id,
toolName: 'list_places',
args: {},
...(result !== undefined ? { result } : {}),
});
const write = (
id: string,
approval: { approved?: boolean; resolution?: 'cancelled' | 'expired' } = {},
outcome: { result?: unknown; isError?: boolean } = {}
): AssistantMessagePart => ({
type: 'tool-call',
toolCallId: id,
toolName: 'create_todo',
args: {},
approval: { id, ...approval },
...outcome,
});
describe('messageBlocks', () => {
it('FE-AI-PARTS-001: keeps text, gathers adjacent reading steps and drops empty text', () => {
const blocks = messageBlocks(
message([
{ type: 'text', text: 'Let me look.' },
read('r1', { ok: 1 }),
read('r2', { ok: 2 }),
{ type: 'text', text: ' ' },
{ type: 'text', text: 'Found two.' },
]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['text', 'steps', 'text']);
expect(blocks[1]).toMatchObject({
kind: 'steps',
steps: [
{ toolCallId: 'r1', running: false },
{ toolCallId: 'r2', running: false },
],
});
});
it('FE-AI-PARTS-002: a step without its result runs only while the answer is being written', () => {
const parts = [read('r1')];
expect(messageBlocks(message(parts), true)[0]).toMatchObject({ steps: [{ running: true }] });
expect(messageBlocks(message(parts), false)[0]).toMatchObject({ steps: [{ running: false }] });
});
it('FE-AI-PARTS-003: the changes of one step share a card, each with its preview, wherever the preview sits', () => {
const blocks = messageBlocks(
message([
write('w1'),
{ type: 'data-trek-change', data: preview('w1') },
write('w2'),
{ type: 'data-trek-change', data: preview('w2') },
]),
false
);
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({
kind: 'change',
items: [
{ toolCallId: 'w1', state: 'pending', preview: { subject: 'Todo w1' } },
{ toolCallId: 'w2', state: 'pending', preview: { subject: 'Todo w2' } },
],
});
});
it('FE-AI-PARTS-004: reads each change state off its approval, result and receipt', () => {
const parts: AssistantMessagePart[] = [
write('applying', { approved: true }),
write('done', { approved: true }, { result: { id: 1 } }),
write('receipt', { approved: true }),
{ type: 'data-trek-applied', data: applied('receipt') },
write('broke', { approved: true }, { result: 'nope', isError: true }),
write('no', { approved: false }),
write('late', { resolution: 'expired' }),
];
const [card] = messageBlocks(message(parts), false);
expect(card?.kind === 'change' && card.items.map((i) => [i.toolCallId, i.state])).toEqual([
['applying', 'applying'],
['done', 'applied'],
['receipt', 'applied'],
['broke', 'failed'],
['no', 'discarded'],
['late', 'expired'],
]);
expect(card?.kind === 'change' && card.items[2]?.applied).toEqual(applied('receipt'));
});
it('FE-AI-PARTS-005: text between two batches of changes splits them into two cards', () => {
const blocks = messageBlocks(message([write('w1'), { type: 'text', text: 'And one more.' }, write('w2')]), false);
expect(blocks.map((b) => b.kind)).toEqual(['change', 'text', 'change']);
});
});
function found(ref: string, extra: Partial<AssistantFoundPlace> = {}): AssistantFoundPlace {
return {
ref,
name: `Place ${ref}`,
address: null,
lat: 41.89,
lng: 12.47,
kind: 'food',
rating: null,
openNow: null,
googlePlaceId: null,
osmId: null,
photoKey: null,
inTripPlaceId: null,
...extra,
};
}
const places = (toolCallId: string, list: AssistantFoundPlace[], more = 0): AssistantMessagePart => ({
type: 'data-trek-places',
data: { toolCallId, places: list, more },
});
function sky(date: string, tempMax: number): AssistantWeatherDay {
return { dayId: null, date, main: 'Clear', tempMax, tempMin: null, rainPct: null, kind: 'forecast' };
}
/** A change of the model that adds a place to day 3: a new one at a spot, or the trip's own by id. */
function adds(id: string, spot: { lat: number; lng: number } | null, placeId: number | null): AssistantMessagePart {
return {
type: 'data-trek-change',
data: {
...preview(id),
effect: {
kind: 'add',
dayId: 3,
fromDayId: null,
placeId,
assignmentId: null,
place: spot && { name: 'New', categoryId: null, ...spot },
},
},
};
}
describe('messageBlocks: found places and weather', () => {
it('FE-AI-PARTS-008: joins the places of all searches into one strip, each once, and steps after it join the row before it', () => {
const blocks = messageBlocks(
message([
read('s1', []),
places('s1', [found('s1:0'), found('s1:1', { googlePlaceId: 'ChIJa' })], 2),
read('s2', []),
places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3),
places('s3', [found('s3:0', { lat: 40 })]),
{ type: 'text', text: 'Three good ones.' },
]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'places', 'text']);
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
// The same name at the same spot, or the same Google id, shows once.
expect(blocks[1]).toMatchObject({
found: { toolCallId: 's3', more: 5, places: [{ ref: 's1:0' }, { ref: 's1:1' }, { ref: 's3:0' }] },
});
});
it('FE-AI-PARTS-009: drops the places a change of the model proposes, and the strip if none is left', () => {
const blocks = messageBlocks(
message([
places('s1', [found('s1:0'), found('s1:1', { lat: 40, inTripPlaceId: 8 }), found('s1:2', { lat: 39 })]),
write('w1'),
adds('w1', { lat: 41.89, lng: 12.47 }, null),
write('w2'),
adds('w2', null, 8),
]),
false
);
expect(blocks[0]).toMatchObject({ kind: 'places', found: { places: [{ ref: 's1:2' }] } });
const none = messageBlocks(
message([places('s1', [found('s1:0')]), write('w1'), adds('w1', { lat: 41.89, lng: 12.47 }, null)]),
false
);
expect(none.map((b) => b.kind)).toEqual(['change']);
});
it('FE-AI-PARTS-010: a place suggested from the strip stays on it and reads as suggested', () => {
const msg = message([
places('s1', [found('s1:0'), found('s1:1', { lat: 40 })]),
{
type: 'tool-call',
toolCallId: 'f1',
toolName: 'add_found_place',
args: { via: 'create_and_assign_place', dayId: 3, name: 'Place s1:0', lat: 41.89, lng: 12.47 },
approval: { id: 'f1' },
},
adds('f1', { lat: 41.89, lng: 12.47 }, null),
]);
const [strip] = messageBlocks(msg, false);
expect(strip).toMatchObject({ kind: 'places', found: { places: [{ ref: 's1:0' }, { ref: 's1:1' }] } });
expect(suggestedFromStrip(msg, found('s1:0'))).toBe(true);
expect(suggestedFromStrip(msg, found('s1:1', { lat: 40 }))).toBe(false);
const inTrip = message([
{
type: 'tool-call',
toolCallId: 'f2',
toolName: 'add_found_place',
args: { placeId: 8 },
approval: { id: 'f2' },
},
]);
expect(suggestedFromStrip(inTrip, found('x', { inTripPlaceId: 8 }))).toBe(true);
expect(suggestedFromStrip(inTrip, found('x', { inTripPlaceId: 9 }))).toBe(false);
});
it('FE-AI-PARTS-014: each suggestion from the strip is a card of its own, apart from the changes around it', () => {
const fromStrip = (id: string, approval: { approved?: boolean } = {}): AssistantMessagePart => ({
type: 'tool-call',
toolCallId: id,
toolName: 'add_found_place',
args: { placeId: 8 },
approval: { id, ...approval },
});
const blocks = messageBlocks(
message([write('w1', { approved: true }), fromStrip('f1', { approved: true }), fromStrip('f2'), write('w2')]),
false
);
expect(blocks.map((b) => (b.kind === 'change' ? b.items.map((i) => i.toolCallId) : b.kind))).toEqual([
['w1'],
['f1'],
['f2'],
['w2'],
]);
});
it('FE-AI-PARTS-011: the weather of all steps is one row, one entry per date, where the first look sat', () => {
const blocks = messageBlocks(
message([
read('w1', {}),
{ type: 'data-trek-weather', data: { days: [sky('2026-11-03', 18), sky('2026-11-02', 20)] } },
{ type: 'text', text: 'Mild.' },
{ type: 'data-trek-weather', data: { days: [sky('2026-11-03', 15)] } },
]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'weather', 'text']);
expect(blocks[1]).toMatchObject({
kind: 'weather',
days: [
{ date: '2026-11-02', tempMax: 20 },
{ date: '2026-11-03', tempMax: 15 },
],
});
});
});
describe('hasUndecided', () => {
it('FE-AI-PARTS-012: is true only while a change waits for the user', () => {
expect(hasUndecided([message([write('w1')])])).toBe(true);
expect(hasUndecided([message([write('w1', { approved: true })])])).toBe(false);
expect(hasUndecided([message([write('w1', { resolution: 'expired' })])])).toBe(false);
expect(hasUndecided([message([read('r1')])])).toBe(false);
});
});
describe('undoLeftMs', () => {
it('FE-AI-PARTS-006: counts down to the end of the undo window and stops at zero', () => {
const now = Date.parse(AT);
expect(undoLeftMs(applied('a', '2026-10-21T08:10:00.000Z'), now)).toBe(10 * 60_000);
expect(undoLeftMs(applied('a', '2026-10-21T07:59:00.000Z'), now)).toBe(0);
expect(undoLeftMs(applied('a', null), now)).toBe(0);
expect(undoLeftMs(null, now)).toBe(0);
});
});
describe('CHANGE_LABELS', () => {
it('FE-AI-PARTS-013: names a route', () => {
expect(CHANGE_LABELS.route.labelKey).toBe('assistant.change.label.route');
});
it('FE-AI-PARTS-007: names every field a change row can carry', () => {
for (const key of ASSISTANT_CHANGE_LABEL_KEYS) {
expect(CHANGE_LABELS[key].labelKey).toBe(`assistant.change.label.${key}`);
}
});
});
@@ -0,0 +1,291 @@
import type {
AssistantApplied,
AssistantChangeEffect,
AssistantChangeLabelKey,
AssistantChangePreview,
AssistantFoundPlace,
AssistantMessage,
AssistantMessagePart,
AssistantPlaces,
AssistantWeatherDay,
} from '@trek/shared';
/**
* How an answer is laid out: its stored parts read as blocks. Text stays
* text; the reading tools of a run sit together as one row of steps; the
* changes the model proposes in one go sit together as one card, each with
* its preview and, once applied, its receipt. The places a search found sit
* as one strip, the newest search's, and the weather the steps looked up as
* one row of days. Pure, so the window and the phone screen lay an answer out
* the same way and a test can pin it.
*/
/** Where the settings card for the user's own AI connection lives. */
export const AI_SETTINGS_PATH = '/settings?tab=integrations';
type ToolCallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
/** One reading tool the assistant ran or runs. */
export interface ToolStep {
toolCallId: string;
toolName: string;
/** Still waiting for its result while the answer is being written. */
running: boolean;
}
/**
* Where a proposed change stands: waiting for the user, being applied,
* applied, failed, discarded, or closed without a decision (expired, or the
* turn was stopped).
*/
export type ChangeState = 'pending' | 'applying' | 'applied' | 'failed' | 'discarded' | 'expired';
export interface ChangeItem {
toolCallId: string;
/** What the card says about it; null until the preview arrived. */
preview: AssistantChangePreview | null;
/** The receipt of an applied change. */
applied: AssistantApplied | null;
state: ChangeState;
}
type MessageBlock =
| { kind: 'text'; key: string; text: string }
| { kind: 'steps'; key: string; steps: ToolStep[] }
| { kind: 'change'; key: string; items: ChangeItem[] }
| { kind: 'places'; key: string; found: AssistantPlaces }
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] };
/** The change "+ Day 3" on a found place makes; the server holds it without a model turn. */
const FOUND_PLACE_TOOL = 'add_found_place';
/** Two points this close (about ten metres) are the same place. */
const SAME_SPOT_DEG = 1e-4;
/** A found place one of the answer's own changes already proposes, so its card would say the same twice. */
function proposed(place: AssistantFoundPlace, effects: readonly AssistantChangeEffect[]): boolean {
return effects.some((effect) => {
if (effect.placeId !== null && effect.placeId === place.inTripPlaceId) return true;
const spot = effect.place;
return (
spot !== null && Math.abs(spot.lat - place.lat) < SAME_SPOT_DEG && Math.abs(spot.lng - place.lng) < SAME_SPOT_DEG
);
});
}
/** Every day the answer's weather steps looked up, one entry per date (the later look wins), by date. */
function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDay[] {
const byDate = new Map<string, AssistantWeatherDay>();
for (const part of parts) {
if (part.type === 'data-trek-weather') for (const day of part.data.days) byDate.set(day.date, day);
}
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
}
/** The handles one place goes by: its ids, and its name at its spot (to about 100 m). */
function placeKeys(place: AssistantFoundPlace): string[] {
const ids = [place.googlePlaceId && `g:${place.googlePlaceId}`, place.osmId && `o:${place.osmId}`];
const spot = `n:${place.name.trim().toLowerCase()}@${place.lat.toFixed(3)},${place.lng.toFixed(3)}`;
return [...ids.filter((key): key is string => !!key), spot];
}
/**
* The places all searches of an answer found, in the order they came, each
* once (also when two searches name it by different ids, as long as name and
* spot agree), with what the searches left out counted together.
*/
function allFound(parts: AssistantMessage['parts']): AssistantPlaces {
const seen = new Set<string>();
const places: AssistantFoundPlace[] = [];
let more = 0;
let toolCallId = '';
for (const part of parts) {
if (part.type !== 'data-trek-places') continue;
toolCallId = part.data.toolCallId;
more += part.data.more;
for (const place of part.data.places) {
const keys = placeKeys(place);
if (keys.some((key) => seen.has(key))) continue;
keys.forEach((key) => seen.add(key));
places.push(place);
}
}
return { toolCallId, places, more };
}
/**
* Adds a change to the change block before it, or starts one. A suggestion
* from the strip is a block of its own, decided apart from the changes before
* and after it, even where the answer joins them.
*/
function pushChange(blocks: MessageBlock[], item: ChangeItem, fromStrip: ReadonlySet<string>): void {
const last = blocks[blocks.length - 1];
const lastId = last?.kind === 'change' ? last.items[last.items.length - 1]?.toolCallId : undefined;
const apart = fromStrip.has(item.toolCallId) || (lastId !== undefined && fromStrip.has(lastId));
if (last?.kind === 'change' && !apart) last.items.push(item);
else blocks.push({ kind: 'change', key: `change-${item.toolCallId}`, items: [item] });
}
/** The steps block a new step joins: the last one, past any strip shown after it. */
function openSteps(blocks: readonly MessageBlock[]): Extract<MessageBlock, { kind: 'steps' }> | null {
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i];
if (block?.kind === 'steps') return block;
if (block?.kind !== 'places' && block?.kind !== 'weather') return null;
}
return null;
}
function changeState(part: ToolCallPart, applied: AssistantApplied | null): ChangeState {
const approval = part.approval;
if (approval?.resolution) return 'expired';
if (approval?.approved === false) return 'discarded';
if (approval?.approved === true) {
if (part.isError) return 'failed';
return applied || part.result !== undefined ? 'applied' : 'applying';
}
return 'pending';
}
/**
* The blocks of a message, in the order its parts came. `writing` says the
* message is still being streamed, so a step without a result is running and
* not merely unanswered.
*
* The places of all its searches join into one strip where the newest search
* sat, each place once, without the ones a change the model proposed in the
* answer already holds; the weather of all steps joins
* into one row where the first look sat. A strip does not split the steps
* around it: a step after it joins the row of steps before it.
*/
export function messageBlocks(message: AssistantMessage, writing: boolean): MessageBlock[] {
const previews = new Map<string, AssistantChangePreview>();
const receipts = new Map<string, AssistantApplied>();
const effects: AssistantChangeEffect[] = [];
// A place the user suggested from the strip stays on it, marked as suggested.
const fromStrip = new Set(
message.parts.flatMap((p) => (p.type === 'tool-call' && p.toolName === FOUND_PLACE_TOOL ? [p.toolCallId] : []))
);
let lastPlaces = -1;
let firstWeather = -1;
message.parts.forEach((part, index) => {
if (part.type === 'data-trek-change') {
previews.set(part.data.toolCallId, part.data);
if (part.data.effect && !fromStrip.has(part.data.toolCallId)) effects.push(part.data.effect);
} else if (part.type === 'data-trek-applied') receipts.set(part.data.toolCallId, part.data);
else if (part.type === 'data-trek-places') lastPlaces = index;
else if (part.type === 'data-trek-weather' && firstWeather < 0) firstWeather = index;
});
const blocks: MessageBlock[] = [];
message.parts.forEach((part, index) => {
if (part.type === 'text') {
if (part.text.trim()) blocks.push({ kind: 'text', key: `text-${index}`, text: part.text });
return;
}
if (part.type === 'data-trek-places' && index === lastPlaces) {
const found = allFound(message.parts);
const places = found.places.filter((place) => !proposed(place, effects));
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } });
return;
}
if (part.type === 'data-trek-weather' && index === firstWeather) {
blocks.push({ kind: 'weather', key: `weather-${index}`, days: weatherDays(message.parts) });
return;
}
if (part.type !== 'tool-call') return;
const preview = previews.get(part.toolCallId) ?? null;
if (part.approval || preview) {
const applied = receipts.get(part.toolCallId) ?? null;
const item: ChangeItem = { toolCallId: part.toolCallId, preview, applied, state: changeState(part, applied) };
pushChange(blocks, item, fromStrip);
return;
}
const step: ToolStep = {
toolCallId: part.toolCallId,
toolName: part.toolName,
running: writing && part.result === undefined,
};
const steps = openSteps(blocks);
if (steps) steps.steps.push(step);
else blocks.push({ kind: 'steps', key: `steps-${part.toolCallId}`, steps: [step] });
});
return blocks;
}
/**
* Whether "+ Day" was already pressed on this found place in the answer: a
* suggestion from the strip names the trip's place by its id, a new one by
* the spot the search found it at.
*/
export function suggestedFromStrip(message: AssistantMessage, place: AssistantFoundPlace): boolean {
return message.parts.some((part) => {
if (part.type !== 'tool-call' || part.toolName !== FOUND_PLACE_TOOL) return false;
const args = part.args as { placeId?: unknown; lat?: unknown; lng?: unknown };
if (place.inTripPlaceId !== null && args.placeId === place.inTripPlaceId) return true;
return (
typeof args.lat === 'number' &&
typeof args.lng === 'number' &&
Math.abs(args.lat - place.lat) < SAME_SPOT_DEG &&
Math.abs(args.lng - place.lng) < SAME_SPOT_DEG
);
});
}
/** Whether a change somewhere in the chat still waits for the user; nothing new can be suggested until then. */
export function hasUndecided(messages: readonly AssistantMessage[]): boolean {
return messages.some((m) =>
m.parts.some(
(p) => p.type === 'tool-call' && !!p.approval && p.approval.approved === undefined && !p.approval.resolution
)
);
}
/** How long an applied change can still be undone, in ms; 0 once it cannot. */
export function undoLeftMs(applied: AssistantApplied | null, now: number): number {
if (!applied?.undoUntil) return 0;
return Math.max(0, Date.parse(applied.undoUntil) - now);
}
/** The label of each field a change row can show. A table, so every key is named where it is read. */
export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }> = {
// A tool's own argument: the row names it in `field`, this is only its fallback.
field: { labelKey: 'assistant.change.label.field' },
order: { labelKey: 'assistant.change.label.order' },
name: { labelKey: 'assistant.change.label.name' },
description: { labelKey: 'assistant.change.label.description' },
address: { labelKey: 'assistant.change.label.address' },
coordinates: { labelKey: 'assistant.change.label.coordinates' },
category: { labelKey: 'assistant.change.label.category' },
day: { labelKey: 'assistant.change.label.day' },
time: { labelKey: 'assistant.change.label.time' },
endTime: { labelKey: 'assistant.change.label.endTime' },
notes: { labelKey: 'assistant.change.label.notes' },
website: { labelKey: 'assistant.change.label.website' },
phone: { labelKey: 'assistant.change.label.phone' },
email: { labelKey: 'assistant.change.label.email' },
openingHours: { labelKey: 'assistant.change.label.openingHours' },
price: { labelKey: 'assistant.change.label.price' },
currency: { labelKey: 'assistant.change.label.currency' },
stopType: { labelKey: 'assistant.change.label.stopType' },
text: { labelKey: 'assistant.change.label.text' },
icon: { labelKey: 'assistant.change.label.icon' },
color: { labelKey: 'assistant.change.label.color' },
position: { labelKey: 'assistant.change.label.position' },
quantity: { labelKey: 'assistant.change.label.quantity' },
weight: { labelKey: 'assistant.change.label.weight' },
bag: { labelKey: 'assistant.change.label.bag' },
packed: { labelKey: 'assistant.change.label.packed' },
dueDate: { labelKey: 'assistant.change.label.dueDate' },
priority: { labelKey: 'assistant.change.label.priority' },
assignee: { labelKey: 'assistant.change.label.assignee' },
type: { labelKey: 'assistant.change.label.type' },
location: { labelKey: 'assistant.change.label.location' },
link: { labelKey: 'assistant.change.label.link' },
confirmation: { labelKey: 'assistant.change.label.confirmation' },
checkIn: { labelKey: 'assistant.change.label.checkIn' },
checkOut: { labelKey: 'assistant.change.label.checkOut' },
stay: { labelKey: 'assistant.change.label.stay' },
members: { labelKey: 'assistant.change.label.members' },
route: { labelKey: 'assistant.change.label.route' },
};
@@ -0,0 +1,119 @@
/**
* FE-AI-SHARE-001 to FE-AI-SHARE-005
*
* What an answer says once it goes to the trip chat: Markdown read as plain
* text, the applied changes on request and only those, and a cut at a word
* end that says so.
*/
import type { AssistantChangePreview, AssistantMessage, AssistantMessagePart } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { chatTextOf, markdownToText, SHARE_MAX_CHARS } from './assistantShare';
const t = (key: string) => (key === 'assistant.share.changesHeading' ? 'Applied:' : `<${key}>`);
function message(parts: AssistantMessagePart[]): AssistantMessage {
return { id: 'msg-3', role: 'assistant', status: 'complete', createdAt: '2026-10-21T08:00:00.000Z', parts };
}
function change(id: string, subject: string, approval: { approved?: boolean }): AssistantMessagePart[] {
const preview: AssistantChangePreview = {
toolCallId: id,
verbKey: 'addPlace',
subject,
rows: [],
dayId: 3,
placeIds: [],
undoable: true,
};
return [
{ type: 'tool-call', toolCallId: id, toolName: 'create_place', args: {}, approval: { id, ...approval } },
{ type: 'data-trek-change', data: preview },
];
}
describe('markdownToText', () => {
it('FE-AI-SHARE-001: reads headings, emphasis, code, lists, links, quotes and tables as plain text', () => {
const text = markdownToText(
[
'## Day 3',
'',
'Go **early** to *Fushimi Inari*, take `JR Nara`.',
'- [Tickets](https://example.com/t)',
'* https://example.com',
'> Bring water',
'',
'| Stop | Time |',
'|---|:--:|',
'| Shrine | 08:00 |',
'```',
'**kept**',
'```',
'',
'',
'',
'Done.',
].join('\n')
);
expect(text).toBe(
[
'Day 3',
'',
'Go early to Fushimi Inari, take JR Nara.',
'• Tickets (https://example.com/t)',
'• https://example.com',
'Bring water',
'',
'Stop, Time',
'Shrine, 08:00',
'**kept**',
'',
'Done.',
].join('\n')
);
});
it('FE-AI-SHARE-002: a link whose text is its address stays one address', () => {
expect(markdownToText('[https://trek.app](https://trek.app)')).toBe('https://trek.app');
});
});
describe('chatTextOf', () => {
it('FE-AI-SHARE-003: adds only the applied changes, under a heading, when asked to', () => {
const msg = message([
{ type: 'text', text: 'I added **two** places.' },
...change('c1', 'Kinkaku-ji', { approved: true }),
{ type: 'data-trek-applied', data: { toolCallId: 'c1', entity: 'place', ids: [9], dayId: 3, undoUntil: null } },
...change('c2', 'Ryoan-ji', { approved: false }),
...change('c3', 'Ginkaku-ji', {}),
]);
expect(chatTextOf(msg, { includeChanges: true, t })).toEqual({
text: 'I added two places.\n\nApplied:\n✓ <assistant.change.verb.addPlace>: Kinkaku-ji',
truncated: false,
hasChanges: true,
});
expect(chatTextOf(msg, { includeChanges: false, t })).toEqual({
text: 'I added two places.',
truncated: false,
hasChanges: true,
});
});
it('FE-AI-SHARE-004: an answer without text or applied changes has nothing to share', () => {
expect(chatTextOf(message(change('c1', 'Kinkaku-ji', {})), { includeChanges: true, t })).toEqual({
text: '',
truncated: false,
hasChanges: false,
});
});
it('FE-AI-SHARE-005: a long answer is cut at a word end within the limit and marked', () => {
const words = Array.from({ length: 1200 }, (_, i) => `word${i}`).join(' ');
const { text, truncated } = chatTextOf(message([{ type: 'text', text: words }]), { includeChanges: false, t });
expect(truncated).toBe(true);
expect(text.length).toBeLessThanOrEqual(SHARE_MAX_CHARS);
expect(text.endsWith('…')).toBe(true);
expect(words.startsWith(text.slice(0, -1))).toBe(true);
expect(words.charAt(text.length - 1)).toBe(' ');
});
});
@@ -0,0 +1,110 @@
import type { AssistantMessage } from '@trek/shared';
import { messageBlocks } from './assistantParts';
/**
* What an answer says once it goes to the trip chat, which shows plain text:
* the Markdown read as text, optionally a line per change the user applied,
* cut to fit a chat message. Pure, so the preview shows exactly what is sent.
*/
/** Room left below the chat's 5000 characters, so the cut and its mark always fit. */
export const SHARE_MAX_CHARS = 4800;
type Translate = (key: string, params?: Record<string, string | number>) => string;
/** A table's divider row (`|---|:--:|`), which has no words. */
const TABLE_RULE = /^\|?(?:\s*:?-+:?\s*\|)+\s*:?-*:?\s*$/;
/** One table row as its cells in a line. */
function tableRow(line: string): string {
return line
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
.filter(Boolean)
.join(', ');
}
/** The marks of one line of running text: emphasis, code, links and pictures. */
function inline(line: string): string {
return line
.replace(/!\[([^\]]*)\]\([^)\s]*\)/g, '$1')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text: string, url: string) => (text === url ? url : `${text} (${url})`))
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, '$1$2')
.replace(/`([^`]+)`/g, '$1');
}
/** One line of Markdown as text, or null for a line that only carries layout (a fence, a table rule). */
function plainLine(line: string): string | null {
if (/^\s*(?:```|~~~)/.test(line)) return null;
if (TABLE_RULE.test(line.trim())) return null;
const heading = /^\s{0,3}#{1,6}\s+(.*)$/.exec(line);
if (heading) return inline(heading[1] ?? '');
const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
if (bullet) return `${bullet[1] ?? ''}• ${inline(bullet[2] ?? '')}`;
const quote = /^\s*>\s?(.*)$/.exec(line);
if (quote) return inline(quote[1] ?? '');
if (line.trim().startsWith('|')) return tableRow(inline(line));
return inline(line);
}
/** Markdown as the chat shows it, as plain text. Code keeps its lines; blank runs shrink to one empty line. */
export function markdownToText(markdown: string): string {
const lines: string[] = [];
let inCode = false;
for (const line of markdown.split('\n')) {
if (/^\s*(?:```|~~~)/.test(line)) {
inCode = !inCode;
continue;
}
if (inCode) {
lines.push(line);
continue;
}
const text = plainLine(line);
if (text !== null) lines.push(text.trimEnd());
}
return lines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** The text cut to `max` characters at the last line or word end before it, marked with an ellipsis. */
function cut(text: string, max: number): { text: string; truncated: boolean } {
if (text.length <= max) return { text, truncated: false };
const head = text.slice(0, max - 1);
const at = Math.max(head.lastIndexOf('\n'), head.lastIndexOf(' '));
// A break far back would drop much of the answer; then the cut goes through the word.
const end = at > max * 0.8 ? at : head.length;
return { text: `${head.slice(0, end).trimEnd()}…`, truncated: true };
}
interface ShareText {
text: string;
truncated: boolean;
/** The answer has applied changes, so "Add the changes" is worth offering. */
hasChanges: boolean;
}
/**
* The chat message an answer becomes: its text, then, when `includeChanges`
* is on, the applied changes in a short list under a heading. Discarded,
* failed and expired changes never go along.
*/
export function chatTextOf(message: AssistantMessage, opts: { includeChanges: boolean; t: Translate }): ShareText {
const blocks = messageBlocks(message, false);
const body = markdownToText(blocks.flatMap((b) => (b.kind === 'text' ? [b.text] : [])).join('\n\n'));
const previews = blocks.flatMap((b) =>
b.kind === 'change' ? b.items.flatMap((i) => (i.state === 'applied' && i.preview ? [i.preview] : [])) : []
);
const lines = previews.map((p) => `✓ ${opts.t(`assistant.change.verb.${p.verbKey}`)}: ${p.subject}`);
const heading = opts.t('assistant.share.changesHeading');
const changes = opts.includeChanges && lines.length > 0 ? [heading, ...lines].join('\n') : '';
const full = [body, changes].filter(Boolean).join('\n\n');
return { ...cut(full, SHARE_MAX_CHARS), hasChanges: lines.length > 0 };
}
@@ -0,0 +1,28 @@
import type { AssistantApplied } from '@trek/shared';
import { showOnMapSearch, showOnMapTarget } from './assistantShowOnMap';
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
toolCallId: 'c1',
entity,
ids,
dayId,
undoUntil: null,
});
describe('assistantShowOnMap', () => {
it('FE-ASSIST-SHOW-001: a place goes to the place, even when it was put on a day', () => {
expect(showOnMapTarget(applied('place', [7], 3))).toEqual({ placeId: 7, dayId: null });
expect(showOnMapSearch(applied('place', [7], 3))).toBe('?place=7');
});
it('FE-ASSIST-SHOW-002: a stop or a note goes to its day', () => {
expect(showOnMapSearch(applied('assignment', [41], 3))).toBe('?day=3');
expect(showOnMapSearch(applied('day_note', [9], 4))).toBe('?day=4');
});
it('FE-ASSIST-SHOW-003: a change with nowhere to point at opens the planner as it is', () => {
expect(showOnMapTarget(applied('packing', [2], null))).toEqual({ placeId: null, dayId: null });
expect(showOnMapSearch(applied('place', [], null))).toBe('');
});
});
@@ -0,0 +1,22 @@
import type { AssistantApplied } from '@trek/shared';
interface ShowOnMapTarget {
/** The place the change made or changed. */
placeId: number | null;
/** The day it lives on, for a note or a stop. */
dayId: number | null;
}
/** Where "Show on map" takes an applied change: the place when it is one, else its day; nothing for the rest. */
export function showOnMapTarget(applied: AssistantApplied): ShowOnMapTarget {
const placeId = applied.entity === 'place' ? (applied.ids[0] ?? null) : null;
return { placeId, dayId: placeId === null ? applied.dayId : null };
}
/** The planner's query for it: `?place=` opens the place, `?day=` selects the day. */
export function showOnMapSearch(applied: AssistantApplied): string {
const { placeId, dayId } = showOnMapTarget(applied);
if (placeId !== null) return `?place=${placeId}`;
if (dayId !== null) return `?day=${dayId}`;
return '';
}
@@ -0,0 +1,25 @@
import type { AssistantApplied } from '@trek/shared';
import { createContext, useContext } from 'react';
/** What the parts deep inside a chat need from whoever shows it. */
export interface AssistantView {
tripId: number;
/** 'window' on the desktop, 'fullscreen' on the phone. */
variant: 'window' | 'fullscreen';
/** How much room the chat has: a narrow window and the phone are 'compact'. */
density?: 'compact' | 'roomy';
/** Shows an applied change in the planner; the button is left out where nothing can show it. */
onShowOnMap?: (applied: AssistantApplied) => void;
/** Selects a day in the planner, from its weather; left out where no planner can show it. */
onFocusDay?: (dayId: number) => void;
/** Brings the map into view where the chat covers it (the phone), before the map moves. */
onRevealMap?: () => void;
}
export const AssistantViewContext = createContext<AssistantView | null>(null);
export function useAssistantView(): AssistantView {
const view = useContext(AssistantViewContext);
if (!view) throw new Error('useAssistantView outside AssistantSurface');
return view;
}
@@ -0,0 +1,150 @@
/**
* 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
* straight-line estimate, free time and clashes, and where the map looks.
*/
import type { AssistantChangeEffect } from '@trek/shared';
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';
// 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 });
const orsay = buildPlace({ id: 11, name: 'Orsay', lat: 48.01, lng: 2.0, duration_minutes: 60 });
const hotel = buildPlace({ id: 12, name: 'Hotel', lat: 48.02, lng: 2.0 });
function plan(): AssignmentsMap {
return {
3: [
buildAssignment({ id: 101, day_id: 3, order_index: 1, place: orsay, assignment_time: '13:00' }),
buildAssignment({ id: 100, day_id: 3, order_index: 0, place: louvre, assignment_time: '09:00' }),
buildAssignment({ id: 102, day_id: 3, order_index: 2, place: hotel, accommodation_id: 7 }),
],
4: [],
};
}
function effect(over: Partial<AssistantChangeEffect>): AssistantChangeEffect {
return { kind: 'add', dayId: 3, fromDayId: null, placeId: null, assignmentId: null, place: null, ...over };
}
const change = (toolCallId: string, e: AssistantChangeEffect, subject: string | null = null): DayChange => ({
toolCallId,
effect: e,
subject,
});
function day(dayId: number, changes: DayChange[], assignments = plan()) {
return buildDayAfter({ dayId, assignments, places: [louvre, orsay, hotel], changes, defaultMode: 'walking' });
}
describe('dayAfterModel', () => {
it('FE-AI-DAYAFTER-001: keeps the day in order without hotel stops, and reads the way and free time', () => {
const { stops } = day(3, []);
expect(stops.map((s) => [s.name, s.status, s.time])).toEqual([
['Louvre', 'kept', '09:00'],
['Orsay', 'kept', '13:00'],
]);
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, shortBy: null });
});
it('FE-AI-DAYAFTER-002: a new place joins at the end, named and placed by the change', () => {
const pantheon = effect({ place: { name: 'Pantheon', lat: 48.02, lng: 2.0, categoryId: null } });
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, 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', () => {
const { stops } = day(4, [change('c1', effect({ dayId: 4, placeId: 11 })), change('c2', effect({ dayId: 4 }))]);
expect(stops.map((s) => s.name)).toEqual(['Orsay']);
const named = day(4, [change('c3', effect({ dayId: 4, placeId: 99 }), 'Sainte-Chapelle')]);
expect(named.stops.map((s) => s.name)).toEqual(['Sainte-Chapelle']);
});
it('FE-AI-DAYAFTER-004: a removed stop stays struck in place; the next one follows the stop before it', () => {
const { stops } = day(3, [change('c1', effect({ kind: 'remove', placeId: 11, assignmentId: 101 }))]);
expect(stops.map((s) => [s.name, s.status, s.toolCallId])).toEqual([
['Louvre', 'kept', null],
['Orsay', 'removed', 'c1'],
]);
expect(stops[1]?.lead).toBeNull();
});
it('FE-AI-DAYAFTER-005: a move leaves one day and joins the other with its own time', () => {
const move = change('c1', effect({ kind: 'move', dayId: 4, fromDayId: 3, placeId: 10, assignmentId: 100 }));
expect(day(3, [move]).stops.map((s) => [s.name, s.status])).toEqual([
['Louvre', 'removed'],
['Orsay', 'kept'],
]);
expect(day(4, [move]).stops).toEqual([
expect.objectContaining({ key: 'new:c1', name: 'Louvre', status: 'new', time: '09:00', lead: null }),
]);
// Within one day the order is the server's to set; the card leaves the day as it is.
const within = change('c2', effect({ kind: 'move', dayId: 3, fromDayId: 3, placeId: 10, assignmentId: 100 }));
expect(day(3, [within]).stops.map((s) => s.status)).toEqual(['kept', 'kept']);
});
it('FE-AI-DAYAFTER-006: stops at the same time clash, a short break is no line, a bike is faster', () => {
const tight = plan();
tight[3] = [
buildAssignment({ id: 100, day_id: 3, order_index: 0, place: louvre, assignment_time: '09:00' }),
buildAssignment({ id: 101, day_id: 3, order_index: 1, place: orsay, assignment_time: '09:30' }),
];
expect(day(3, [], tight).stops[1]?.lead?.overlapsWith).toBe('Louvre');
const short = plan();
short[3] = [
buildAssignment({
id: 100,
day_id: 3,
order_index: 0,
place: louvre,
assignment_time: '09:00',
leg_transport_mode: 'cycling',
}),
buildAssignment({ id: 101, day_id: 3, order_index: 1, place: orsay, assignment_time: '10:30' }),
];
const lead = day(3, [], short).stops[1]?.lead;
expect(lead?.travelMinutes).toBe(6);
// 10:00 to 10:30 less the ride leaves 24 minutes, under GAP_MIN_MINUTES.
expect(lead?.freeMinutes).toBeNull();
});
it('FE-AI-DAYAFTER-007: only a clock time counts as a time', () => {
expect(minutesOf('09:30')).toBe(570);
expect(minutesOf('7:05 am')).toBe(425);
expect(minutesOf('25:00')).toBeNull();
expect(minutesOf('noon')).toBeNull();
expect(minutesOf(null)).toBeNull();
});
it('FE-AI-DAYAFTER-008: the days follow the changes, and the map looks at the places and their days', () => {
const move = change('c1', effect({ kind: 'move', dayId: 4, fromDayId: 3, placeId: 10, assignmentId: 100 }));
const pantheon = { name: 'Pantheon', lat: 41.9, lng: 12.48, categoryId: null };
const add = change('c2', effect({ dayId: 4, place: pantheon }));
expect(changedDays([move, add])).toEqual([4, 3]);
expect(changePoints([move, add], plan(), [louvre, orsay, hotel])).toEqual([
[48.0, 2.0],
[41.9, 12.48],
[48.01, 2.0],
[48.0, 2.0],
[48.02, 2.0],
]);
});
it('FE-AI-DAYAFTER-009: a way longer than the time between two stops is too tight by the difference', () => {
// 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 });
});
});
@@ -0,0 +1,236 @@
import type { AssistantChangeEffect } from '@trek/shared';
import { dayPoints, type LatLng } from '../../store/assistantMapModel';
import type { Assignment, AssignmentsMap, Place } from '../../types';
import { haversineKm } from '../../utils/geo';
/**
* A day as it would read once the waiting changes of a card ran: the stops it
* keeps, the ones that join it and the ones that leave it, each with the way
* from the stop before. Pure and React-free, so the change card draws it and a
* test can pin it.
*
* Only what the assistant can do shows up here. It cannot set a time or a
* position, so a stop that joins the day lands at its end, the way the server
* appends it. The way between two stops is a straight line bent by a detour
* factor and read at the leg's speed: a quick estimate, which the card marks
* with "≈", and no router request per suggestion.
*/
/** One waiting change, as much of it as the day plan needs. */
export interface DayChange {
toolCallId: string;
effect: AssistantChangeEffect;
/** What the card calls it, for a stop the trip does not hold yet. */
subject: string | null;
}
type StopStatus = 'kept' | 'new' | 'removed';
/** How a stop follows the one before it on the day. */
interface StopLead {
/** Estimated minutes on the way there; null without both positions. */
travelMinutes: number | null;
/** Minutes left free between the two, the way there taken off; null when the times do not say. */
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 {
key: string;
name: string;
status: StopStatus;
/** The change that adds or removes it; null for a stop the card leaves alone. */
toolCallId: string | null;
/** The planned start as written, "09:30"; null when none is set. */
time: string | null;
/** Null for the first stop of the day and for one that leaves it. */
lead: StopLead | null;
}
export interface DayAfter {
dayId: number;
stops: TimelineStop[];
}
/** Free time below this is not worth a line. */
const GAP_MIN_MINUTES = 30;
/** Roads and paths are longer than the straight line. */
const DETOUR_FACTOR = 1.3;
/** km/h on a leg, by its travel mode. */
function speedOf(mode: string): number {
if (mode === 'walking') return 4.5;
if (mode === 'cycling' || mode === 'bicycle' || mode === 'bike') return 15;
return 50;
}
/** Minutes since midnight of an "HH:MM" value; null for anything else. */
export function minutesOf(value: string | null | undefined): number | null {
const match = value ? /^(\d{1,2}):(\d{2})/.exec(value) : null;
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
return hours < 24 && minutes < 60 ? hours * 60 + minutes : null;
}
interface Draft {
stop: Omit<TimelineStop, 'lead'>;
lat: number | null;
lng: number | null;
start: number | null;
end: number | null;
legMode: string | null;
}
function draftOf(visit: Assignment, status: StopStatus, toolCallId: string | null, key: string): Draft {
const place = visit.place;
const time = visit.assignment_time ?? place?.place_time ?? null;
const start = minutesOf(time);
const explicitEnd = minutesOf(visit.assignment_end_time ?? place?.end_time ?? null);
const duration = place?.duration_minutes;
const end = explicitEnd ?? (start !== null && typeof duration === 'number' && duration > 0 ? start + duration : null);
return {
stop: { key, name: place?.name ?? '', status, toolCallId, time: start === null ? null : time },
lat: typeof place?.lat === 'number' ? place.lat : null,
lng: typeof place?.lng === 'number' ? place.lng : null,
start,
end,
legMode: visit.leg_transport_mode ?? null,
};
}
function findVisit(assignments: AssignmentsMap, dayId: number | null, assignmentId: number | null) {
if (dayId === null || assignmentId === null) return undefined;
return (assignments[dayId] ?? []).find((v) => v.id === assignmentId);
}
/** A stop the change brings onto the day; null when there is nothing to name it by. */
function joiningDraft(
change: DayChange,
assignments: AssignmentsMap,
places: ReadonlyMap<number, Place>
): Draft | null {
const { effect, toolCallId } = change;
const key = `new:${toolCallId}`;
const moved = effect.kind === 'move' ? findVisit(assignments, effect.fromDayId, effect.assignmentId) : undefined;
if (moved) return draftOf(moved, 'new', toolCallId, key);
const place = effect.placeId === null ? undefined : places.get(effect.placeId);
const name = effect.place?.name ?? place?.name ?? change.subject;
if (!name) return null;
const lat = effect.place?.lat ?? place?.lat ?? null;
const lng = effect.place?.lng ?? place?.lng ?? null;
return {
stop: { key, name, status: 'new', toolCallId, time: null },
lat,
lng,
start: null,
end: null,
legMode: null,
};
}
/** The visits of the day that a change takes off it, by assignment, with the change that does. */
function leavingVisits(dayId: number, changes: readonly DayChange[]): Map<number, string> {
const leaving = new Map<number, string>();
for (const { effect, toolCallId } of changes) {
if (effect.assignmentId === null) continue;
const removes = effect.kind === 'remove' && effect.dayId === dayId;
const movesAway = effect.kind === 'move' && effect.fromDayId === dayId && effect.dayId !== dayId;
if (removes || movesAway) leaving.set(effect.assignmentId, toolCallId);
}
return leaving;
}
function leadOf(prev: Draft, cur: Draft, defaultMode: string): StopLead {
const travelMinutes =
prev.lat !== null && prev.lng !== null && cur.lat !== null && cur.lng !== null
? Math.round(
((haversineKm({ lat: prev.lat, lng: prev.lng }, { lat: cur.lat, lng: cur.lng }) * DETOUR_FACTOR) /
speedOf(prev.legMode ?? defaultMode)) *
60
)
: null;
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);
if (free < 0) return { travelMinutes, ...none, shortBy: -free };
return { travelMinutes, ...none, freeMinutes: free >= GAP_MIN_MINUTES ? free : null };
}
/**
* The day after the changes: its own stops in their order (hotel stops left
* out, as the day plan hides them), the ones a change takes away marked, the
* ones that join it at the end.
*/
export function buildDayAfter(input: {
dayId: number;
assignments: AssignmentsMap;
places: readonly Place[];
changes: readonly DayChange[];
defaultMode: string | null | undefined;
}): DayAfter {
const { dayId, assignments, changes } = input;
const places = new Map(input.places.map((p) => [p.id, p]));
const leaving = leavingVisits(dayId, changes);
const own = [...(assignments[dayId] ?? [])]
.filter((v) => !v.accommodation_id)
.sort((a, b) => a.order_index - b.order_index);
const drafts = own.map((v) => {
const by = leaving.get(v.id) ?? null;
return draftOf(v, by ? 'removed' : 'kept', by, `a:${v.id}`);
});
for (const change of changes) {
const { effect } = change;
if (effect.dayId !== dayId || effect.kind === 'remove' || effect.fromDayId === dayId) continue;
const draft = joiningDraft(change, assignments, places);
if (draft) drafts.push(draft);
}
const defaultMode = input.defaultMode ?? 'walking';
let prev: Draft | null = null;
const stops = drafts.map((d) => {
if (d.stop.status === 'removed') return { ...d.stop, lead: null };
const lead = prev ? leadOf(prev, d, defaultMode) : null;
prev = d;
return { ...d.stop, lead };
});
return { dayId, stops };
}
/**
* Where the map should look to show a card's changes: each change's own place
* (a new one by its coordinates, a planned one by the trip's) and the stops of
* the days they touch, so the suggestion shows beside the rest of its day.
*/
export function changePoints(
changes: readonly DayChange[],
assignments: AssignmentsMap,
places: readonly Place[]
): LatLng[] {
const byId = new Map(places.map((p) => [p.id, p]));
const points: LatLng[] = [];
for (const { effect } of changes) {
const place = effect.placeId === null ? undefined : byId.get(effect.placeId);
const lat = effect.place?.lat ?? place?.lat;
const lng = effect.place?.lng ?? place?.lng;
if (typeof lat === 'number' && typeof lng === 'number') points.push([lat, lng]);
}
return [...points, ...dayPoints(assignments, changedDays(changes))];
}
/** The days a card's changes touch, in the order they come: where each lands, then where a move leaves. */
export function changedDays(changes: readonly DayChange[]): number[] {
const days: number[] = [];
const add = (id: number | null) => {
if (id !== null && !days.includes(id)) days.push(id);
};
for (const { effect } of changes) {
add(effect.dayId);
add(effect.fromDayId);
}
return days;
}
@@ -0,0 +1,45 @@
/**
* FE-AI-PROVIDER-001 to FE-AI-PROVIDER-004
*
* Pasting a key picks the provider by its prefix, so the most specific prefix
* has to win; pasting the ChatGPT sign-in address submits it, so only that
* address may count.
*/
import { aiConnectionKindSchema } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { AI_PROVIDERS, detectKeyKind, isChatgptCallback, providerLabel } from './providerCatalog';
describe('providerCatalog', () => {
it('FE-AI-PROVIDER-001: reads the provider from the key prefix, most specific first', () => {
expect(detectKeyKind('sk-ant-api03-abcdef')).toBe('anthropic');
expect(detectKeyKind(' sk-or-v1-abcdef\n')).toBe('openrouter');
expect(detectKeyKind('AIzaSyAbcdef')).toBe('gemini');
expect(detectKeyKind('AQ.Ab8RN6IVSexample')).toBe('gemini');
expect(detectKeyKind('sk-proj-abcdef')).toBe('openai');
expect(detectKeyKind('sk-abcdef')).toBe('openai');
expect(detectKeyKind('ghp_abcdef')).toBeNull();
expect(detectKeyKind('')).toBeNull();
});
it('FE-AI-PROVIDER-002: accepts only the loopback sign-in address, any port', () => {
expect(isChatgptCallback('http://127.0.0.1:1455/auth/callback?code=abc&state=xyz')).toBe(true);
expect(isChatgptCallback(' http://127.0.0.1:1457/auth/callback?code=abc ')).toBe(true);
expect(isChatgptCallback('http://localhost:1455/auth/callback?code=abc')).toBe(false);
expect(isChatgptCallback('https://127.0.0.1:1455/auth/callback?code=abc')).toBe(false);
expect(isChatgptCallback('http://127.0.0.1:1455/other?code=abc')).toBe(false);
expect(isChatgptCallback('not a url')).toBe(false);
});
it('FE-AI-PROVIDER-003: fills {provider} with the brand, or an own server with its host', () => {
expect(providerLabel('anthropic', 'api.anthropic.com')).toBe('Anthropic');
expect(providerLabel('chatgpt', 'api.openai.com')).toBe('ChatGPT');
expect(providerLabel('custom', 'ollama.lan')).toBe('ollama.lan');
});
it('FE-AI-PROVIDER-004: every connection kind has an entry with a hint', () => {
for (const kind of aiConnectionKindSchema.options) {
expect(AI_PROVIDERS[kind].kind).toBe(kind);
expect(AI_PROVIDERS[kind].hintKey).toMatch(/^assistant\./);
}
});
});
@@ -0,0 +1,121 @@
import type { AiApiKeyKind, AiConnectionKind } from '@trek/shared';
/**
* The AI providers as the client names and explains them. Brand names live
* here and nowhere in the locales: a translated string takes them through a
* `{provider}` placeholder. The tiles show a neutral monogram, never a logo.
*
* Which model a new connection starts on is the server's choice; the client
* only lists what the provider offers once connected.
*/
interface AiProviderInfo {
kind: AiConnectionKind;
/** The brand name, never translated; null for an own server, which has no brand. */
label: string | null;
/** The letter on the provider's tile; null for an own server, which shows a server icon. */
monogram: string | null;
/** Where a key for it is made; null where there is none to copy. */
keyUrl: string | null;
/** The one-line help under the key field or tile. */
hintKey: string;
/** The provider offers a free tier worth naming; a key may still be on a paid one. */
free: boolean;
}
export const AI_PROVIDERS: Record<AiConnectionKind, AiProviderInfo> = {
anthropic: {
kind: 'anthropic',
label: 'Anthropic',
monogram: 'A',
keyUrl: 'https://console.anthropic.com/settings/keys',
hintKey: 'assistant.connect.hintAnthropic',
free: false,
},
openai: {
kind: 'openai',
label: 'OpenAI',
monogram: 'O',
keyUrl: 'https://platform.openai.com/api-keys',
hintKey: 'assistant.connect.hintOpenai',
free: false,
},
gemini: {
kind: 'gemini',
label: 'Gemini',
monogram: 'G',
keyUrl: 'https://aistudio.google.com/app/apikey',
hintKey: 'assistant.connect.hintGemini',
free: true,
},
openrouter: {
kind: 'openrouter',
label: 'OpenRouter',
monogram: 'R',
keyUrl: 'https://openrouter.ai/keys',
hintKey: 'assistant.connect.hintOpenrouter',
free: false,
},
chatgpt: {
kind: 'chatgpt',
label: 'ChatGPT',
monogram: 'C',
keyUrl: null,
hintKey: 'assistant.connect.chatgptHint',
free: false,
},
custom: {
kind: 'custom',
label: null,
monogram: null,
keyUrl: null,
hintKey: 'assistant.admin.customHint',
free: false,
},
};
/** The kinds a pasted key can be, in the order the tiles list them. */
export const API_KEY_KINDS: readonly AiApiKeyKind[] = ['anthropic', 'openai', 'gemini', 'openrouter'];
/** What fills `{provider}`: the brand, or for an own server its host. */
export function providerLabel(kind: AiConnectionKind, host: string): string {
return AI_PROVIDERS[kind].label ?? host;
}
// Most specific first: an OpenRouter or Anthropic key also starts with "sk-".
const KEY_PREFIXES: readonly [string, AiApiKeyKind][] = [
['sk-ant-', 'anthropic'],
['sk-or-', 'openrouter'],
['AIza', 'gemini'],
// Google's newer AI Studio keys.
['AQ.', 'gemini'],
['sk-', 'openai'],
];
/**
* The provider a pasted key belongs to, read from its prefix; null when it
* matches none. Only a convenience: key formats change, so an unknown prefix
* asks which provider it is instead of refusing, and saving tests the key.
*/
export function detectKeyKind(raw: string): AiApiKeyKind | null {
const key = raw.trim();
return KEY_PREFIXES.find(([prefix]) => key.startsWith(prefix))?.[1] ?? null;
}
/** Where a ChatGPT plan's usage is shown, linked when it ran out. */
export const CHATGPT_USAGE_URL = 'https://chatgpt.com/settings/usage';
/**
* Whether pasted text is the address ChatGPT's sign-in ended on. It sends the
* browser to a loopback address nothing listens on, so the browser shows an
* error page and the user pastes its address back; only the port may differ.
*/
export function isChatgptCallback(text: string): boolean {
let url: URL;
try {
url = new URL(text.trim());
} catch {
return false;
}
return url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.pathname === '/auth/callback';
}
@@ -0,0 +1,20 @@
import { useAssistantStore } from '../../store/assistantStore';
/**
* The ways into the assistant from inside a trip: "Ask" about a place, on the
* desktop inspector and the phone's place sheet, opens it with the place as
* the chat's context chip; the phone's Mehr tile just opens it. Offered only
* while the assistant can run for this user, like every other way in.
*/
export function useAssistantAsk(tripId: number | null) {
const available = useAssistantStore((s) => s.status?.available === true);
const openWith = useAssistantStore((s) => s.openWith);
const open = useAssistantStore((s) => s.open);
return {
available: available && tripId !== null,
askAbout: (placeId: number) => {
if (tripId !== null) openWith(tripId, { placeIds: [placeId] });
},
open,
};
}
@@ -0,0 +1,84 @@
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
import { type DragEvent, useState } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useToast } from '../shared/Toast';
/** What the booking import reads (the server checks again), and how big one file may be. */
const ATTACH_EXTENSIONS: readonly string[] = [
'.pdf',
'.eml',
'.pkpass',
'.ics',
'.html',
'.htm',
'.txt',
...LLM_PHOTO_EXTENSIONS,
];
const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
function extensionOf(name: string): string {
const dot = name.lastIndexOf('.');
return dot < 0 ? '' : name.slice(dot).toLowerCase();
}
function hasFiles(e: DragEvent): boolean {
return Array.from(e.dataTransfer.types).includes('Files');
}
/**
* Hands a booking file to the chat, from the paperclip, a paste or a drop on
* the window. One file at a time, refused here with a toast when it is not
* one the import reads or too big, so a wrong file never costs a round trip.
* The drop handlers go on the whole chat; `dragging` lights its drop zone.
*/
export function useAssistantAttach(tripId: number) {
const { t } = useTranslation();
const toast = useToast();
const attach = useAssistantStore((s) => s.attach);
const busy = useAssistantStore((s) => chatOf(s, tripId).isRunning);
const [dragging, setDragging] = useState(false);
const take = (files: FileList | File[] | null | undefined): boolean => {
const file = files ? Array.from(files)[0] : undefined;
if (!file) return false;
if (busy) return true;
if (!ATTACH_EXTENSIONS.includes(extensionOf(file.name))) {
toast.warning(t('assistant.attach.unsupported'));
return true;
}
if (file.size > MAX_ATTACH_BYTES) {
toast.warning(t('assistant.attach.tooLarge'));
return true;
}
void attach(tripId, file);
return true;
};
const dropZone = {
onDragEnter: (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
setDragging(true);
},
onDragOver: (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
},
onDragLeave: (e: DragEvent) => {
// Leaving for a child is not leaving the zone.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setDragging(false);
},
onDrop: (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
setDragging(false);
take(e.dataTransfer.files);
},
};
return { take, busy, dragging, dropZone, accept: ATTACH_EXTENSIONS.join(',') };
}
@@ -0,0 +1,75 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import type { Place } from '../../types';
import { dayLabel } from '../../utils/dayLabel';
/**
* What the next question is about: the place the user asked from, or else the
* day selected in the planner. The chip above the composer names it, and the
* suggestions turn to it.
*/
/**
* The selected day becomes the focus by itself, quietly, and follows the
* selection. A removed chip stays away until another day is selected, and a
* place asked about is never replaced by the day. Run once per chat.
*/
export function useFollowSelectedDay(tripId: number): void {
const setFocus = useAssistantStore((s) => s.setFocus);
// The planner's own store holds the trip it shows; for any other trip there is no selection to follow.
const here = useTripStore((s) => s.trip?.id === tripId);
const selectedDayId = useTripStore((s) => s.selectedDayId);
useEffect(() => {
if (!here) return;
const current = chatOf(useAssistantStore.getState(), tripId).focus;
if (current?.placeIds?.length) return;
if (selectedDayId === null) {
if (current) setFocus(tripId, null);
} else if (current?.dayId !== selectedDayId) {
setFocus(tripId, { dayId: selectedDayId });
}
}, [here, selectedDayId, tripId, setFocus]);
}
interface AssistantFocusView {
/** The place or day named on the chip; null when nothing is in focus or it is not at hand. */
label: string | null;
kind: 'place' | 'day' | null;
/** The place on a place chip, for its picture. */
place: Place | null;
clear: () => void;
}
export function useAssistantFocus(tripId: number): AssistantFocusView {
const { t, locale } = useTranslation();
const focus = useAssistantStore((s) => chatOf(s, tripId).focus);
const setFocus = useAssistantStore((s) => s.setFocus);
const here = useTripStore((s) => s.trip?.id === tripId);
const days = useTripStore((s) => s.days);
const places = useTripStore((s) => s.places);
const named = useMemo((): { label: string; kind: 'place' | 'day'; place: Place | null } | null => {
if (!here || !focus) return null;
const placeIds = focus.placeIds ?? [];
if (placeIds.length > 0) {
const first = places.find((p) => p.id === placeIds[0]);
if (!first) return null;
const label = placeIds.length > 1 ? `${first.name} +${placeIds.length - 1}` : first.name;
return { kind: 'place', label, place: first };
}
const index = days.findIndex((d) => d.id === focus.dayId);
const day = days[index];
return day ? { kind: 'day', label: dayLabel(day, index, t, locale), place: null } : null;
}, [here, focus, places, days, t, locale]);
return {
label: named?.label ?? null,
kind: named?.kind ?? null,
place: named?.place ?? null,
clear: () => setFocus(tripId, null),
};
}
@@ -0,0 +1,138 @@
/**
* FE-AI-HINTS-001 to FE-AI-HINTS-006
*
* The plan hints as the views get them: worded with the day's name, gated on
* the assistant, the setting, the trip in the planner and its phase, with the
* stays read only when asked for, and a tap that focuses and asks.
*/
import { act, renderHook, waitFor } from '@testing-library/react';
import type { AiStatusResponse } from '@trek/shared';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
import { resetAllStores } from '../../../tests/helpers/store';
import { TranslationProvider } from '../../i18n/TranslationContext';
import { useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import type { Assignment, Place } from '../../types';
const repo = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock('../../repo/accommodationRepo', () => ({ accommodationRepo: repo }));
const { useAssistantHints, useAssistantHintsSetting } = await import('./useAssistantHints');
const INITIAL_ASSISTANT = useAssistantStore.getState();
const TRIP = 5;
const wrapper = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
function timed(id: number, dayId: number, time: string, end: string): Assignment {
const place = buildPlace({ id: 100 + id, name: `Stop ${id}`, place_time: time, end_time: end, lat: 0, lng: 0 });
return buildAssignment({ id, day_id: dayId, place_id: place.id, order_index: id, place });
}
function seedTrip() {
const days = [
buildDay({ id: 1, trip_id: TRIP, date: '2099-06-01', title: 'Louvre day' }),
buildDay({ id: 2, trip_id: TRIP, date: '2099-06-02', title: 'Last day' }),
];
const stops = [timed(1, 1, '10:00', '12:00'), timed(2, 1, '11:00', '12:30')];
useTripStore.setState({
trip: buildTrip({ id: TRIP, start_date: '2099-06-01', end_date: '2099-06-02' }),
days,
places: stops.map((a) => a.place as Place),
assignments: { '1': stops },
reservations: [],
dayNotes: {},
});
}
beforeEach(() => {
window.localStorage.clear();
resetAllStores();
vi.clearAllMocks();
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
repo.list.mockResolvedValue({ accommodations: [] });
seedTrip();
});
afterEach(() => useAssistantStore.setState(INITIAL_ASSISTANT, true));
const render = (tripId: number | null = TRIP, withStays = false) =>
renderHook(() => useAssistantHints(tripId, { withStays }), { wrapper });
describe('useAssistantHints', () => {
it('FE-AI-HINTS-001: words a hint with the name of its day, and a tap focuses the day and its places and asks', async () => {
const setFocus = vi.fn();
const send = vi.fn().mockResolvedValue(undefined);
useAssistantStore.setState({ setFocus, send });
const { result } = render();
await waitFor(() => expect(result.current.hints).toHaveLength(1));
const [hint] = result.current.hints;
expect(hint.text).toBe('Stop 1 and Stop 2 overlap on Louvre day');
act(() => result.current.ask(hint));
expect(setFocus).toHaveBeenCalledWith(TRIP, { dayId: 1, placeIds: [101, 102] });
expect(send).toHaveBeenCalledWith(TRIP, 'Stop 1 and Stop 2 overlap on Louvre day. How should we sort that out?');
});
it('FE-AI-HINTS-002: says nothing without the assistant, with hints off, for another trip, or for a trip that is over', () => {
useAssistantStore.setState({ status: { available: false } as AiStatusResponse });
expect(render().result.current.hints).toEqual([]);
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
useSettingsStore.setState((s) => ({ settings: { ...s.settings, assistant_hints: false } }));
expect(render().result.current.hints).toEqual([]);
useSettingsStore.setState((s) => ({ settings: { ...s.settings, assistant_hints: true } }));
expect(render(TRIP + 1).result.current.hints).toEqual([]);
expect(render(null).result.current.hints).toEqual([]);
expect(render().result.current.hints).toHaveLength(1);
useTripStore.setState({ trip: buildTrip({ id: TRIP, start_date: '2000-06-01', end_date: '2000-06-02' }) });
expect(render().result.current.hints).toEqual([]);
});
it('FE-AI-HINTS-003: a dismissed hint stays away, and the launcher counts unseen warnings until they are seen', async () => {
const { result } = render();
await waitFor(() => expect(result.current.unseenWarn).toBe(1));
act(() => result.current.markWarnsSeen());
expect(result.current.unseenWarn).toBe(0);
expect(result.current.hints).toHaveLength(1);
act(() => result.current.dismiss(result.current.hints[0].id));
expect(result.current.hints).toEqual([]);
expect(render().result.current.hints).toEqual([]);
});
it('FE-AI-HINTS-004: reads the stays only when asked, and then names a night without one', async () => {
const { result: quiet } = render(TRIP, false);
await waitFor(() => expect(quiet.current.hints).toHaveLength(1));
expect(repo.list).not.toHaveBeenCalled();
const { result } = render(TRIP, true);
await waitFor(() => expect(result.current.hints.map((h) => h.rule)).toEqual(['overlap', 'noStay']));
expect(repo.list).toHaveBeenCalledWith(TRIP);
expect(result.current.hints[1].text).toBe('Nowhere to stay yet for the night after Louvre day');
});
it('FE-AI-HINTS-005: stays that cannot be read leave the nights alone', async () => {
repo.list.mockRejectedValue(new Error('offline and not cached'));
const { result } = render(TRIP, true);
await waitFor(() => expect(repo.list).toHaveBeenCalled());
await waitFor(() => expect(result.current.hints.map((h) => h.rule)).toEqual(['overlap']));
});
});
describe('useAssistantHintsSetting', () => {
it('FE-AI-HINTS-006: is on by default, and the switch saves the opposite', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined);
useSettingsStore.setState({ updateSetting });
const { result } = renderHook(() => useAssistantHintsSetting(), { wrapper });
expect(result.current.on).toBe(true);
act(() => result.current.toggle());
expect(updateSetting).toHaveBeenCalledWith('assistant_hints', false);
});
});
@@ -0,0 +1,195 @@
import type { AssistantFocus } from '@trek/shared';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../i18n';
import { accommodationRepo } from '../../repo/accommodationRepo';
import { useAssistantHintsStore } from '../../store/assistantHintsStore';
import { useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import type { Accommodation } from '../../types';
import { dayLabel } from '../../utils/dayLabel';
import { localIsoDate } from '../../utils/localDate';
import { useToast } from '../shared/Toast';
import { type AssistantHint, type HintRule, tripHints } from './assistantHintRules';
import { tripPhase } from './assistantModel';
export interface ReadyHint {
id: string;
rule: HintRule;
level: AssistantHint['level'];
/** The line in the list. */
text: string;
/** The question a tap sends. */
prompt: string;
focus: AssistantFocus | null;
}
export interface AssistantHintsView {
/** The hints not dismissed, worded, warnings first. */
hints: ReadyHint[];
/** Warnings the user has not been shown yet: what the launcher points at. */
unseenWarn: number;
markSeen: (ids: readonly string[]) => void;
/** Counts every current warning as seen, as when the chat is opened. */
markWarnsSeen: () => void;
dismiss: (id: string) => void;
/** Puts the hint's day or places in focus and sends its question. */
ask: (hint: ReadyHint) => void;
}
/**
* The trip's stays, which the planner keeps to itself, read through the
* offline core for the night rule. Read again when the days or bookings
* change, since a hotel booking moves its stay. Null while not known, and
* when they cannot be read, which keeps that rule quiet.
*/
function useTripStays(tripId: number | null, days: unknown, reservations: unknown): Accommodation[] | null {
const [stays, setStays] = useState<{ tripId: number; list: Accommodation[] } | null>(null);
useEffect(() => {
if (tripId === null) return;
let cancelled = false;
const read = async () => {
try {
const { accommodations } = await accommodationRepo.list(tripId);
if (!cancelled) setStays({ tripId, list: accommodations });
} catch {
// Stays that cannot be read are not known: no word about nights rather than a wrong one.
if (!cancelled) setStays(null);
}
};
void read();
return () => {
cancelled = true;
};
}, [tripId, days, reservations]);
return stays && stays.tripId === tripId ? stays.list : null;
}
/** The planner's data for this trip, or null when the planner holds another trip or none. */
function useTripPlan(tripId: number | null) {
return useTripStore(
useShallow((s) =>
s.trip && s.trip.id === tripId
? {
trip: s.trip,
days: s.days,
places: s.places,
assignments: s.assignments,
reservations: s.reservations,
dayNotes: s.dayNotes,
}
: null
)
);
}
/**
* The plan hints for a trip, worded and ready, with what the user did with
* them. Nothing while the assistant cannot run, the user turned hints off,
* the planner does not hold this trip, or the trip is over or still empty.
* `withStays` reads the stays as well, for the night rule; only the empty
* chat asks for that, so the launcher and the phone's tile cost no request.
*/
export function useAssistantHints(tripId: number | null, { withStays }: { withStays: boolean }): AssistantHintsView {
const { t, locale } = useTranslation();
const available = useAssistantStore((s) => s.status?.available === true);
const setFocus = useAssistantStore((s) => s.setFocus);
const send = useAssistantStore((s) => s.send);
const on = useSettingsStore((s) => s.settings.assistant_hints !== false);
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
const is12h = useSettingsStore((s) => s.settings.time_format === '12h');
const plan = useTripPlan(tripId);
const today = localIsoDate();
const phase = plan
? tripPhase({
startDate: plan.trip.start_date ?? null,
endDate: plan.trip.end_date ?? null,
hasPlaces: plan.places.length > 0,
today,
})
: null;
const active = available && on && (phase === 'upcoming' || phase === 'running');
const stays = useTripStays(withStays && active ? tripId : null, plan?.days, plan?.reservations);
const marks = useAssistantHintsStore((s) => (tripId === null ? undefined : s.byTrip[tripId]));
const load = useAssistantHintsStore((s) => s.load);
const storeDismiss = useAssistantHintsStore((s) => s.dismiss);
const storeMarkSeen = useAssistantHintsStore((s) => s.markSeen);
useEffect(() => {
if (tripId !== null) load(tripId);
}, [tripId, load]);
const raw = useMemo(
() =>
active && plan
? tripHints({ today, ...plan, accommodations: withStays ? stays : null, distanceUnit, is12h })
: [],
[active, plan, today, withStays, stays, distanceUnit, is12h]
);
// Until the marks are read, a dismissed hint would flash back for a frame: show none instead.
const hints = useMemo((): ReadyHint[] => {
if (!marks || !plan) return [];
return raw
.filter((h) => !marks.dismissed.includes(h.id))
.map((h) => {
const index = h.dayId === null ? -1 : plan.days.findIndex((d) => d.id === h.dayId);
const params = index < 0 ? h.params : { ...h.params, day: dayLabel(plan.days[index], index, t, locale) };
return {
id: h.id,
rule: h.rule,
level: h.level,
text: t(h.textKey, params),
prompt: t(h.promptKey, params),
focus: h.focus,
};
});
}, [raw, marks, plan, t, locale]);
const unseen = useMemo(
() => (marks ? hints.filter((h) => h.level === 'warn' && !marks.seen.includes(h.id)).map((h) => h.id) : []),
[hints, marks]
);
const markSeen = useCallback(
(ids: readonly string[]) => {
if (tripId !== null && ids.length > 0) storeMarkSeen(tripId, ids);
},
[tripId, storeMarkSeen]
);
return {
hints,
unseenWarn: unseen.length,
markSeen,
markWarnsSeen: () => markSeen(unseen),
dismiss: (id) => {
if (tripId !== null) storeDismiss(tripId, id);
},
ask: (hint) => {
if (tripId === null) return;
// A hint about the trip as a whole leaves the focus the user had.
if (hint.focus) setFocus(tripId, hint.focus);
void send(tripId, hint.prompt);
},
};
}
/** The menu switch for the hints, in the window's menu and the phone's. On unless turned off. */
export function useAssistantHintsSetting(): { on: boolean; toggle: () => void } {
const on = useSettingsStore((s) => s.settings.assistant_hints !== false);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const toast = useToast();
const { t } = useTranslation();
return {
on,
toggle: () => {
updateSetting('assistant_hints', !on).catch((e: unknown) =>
toast.error(e instanceof Error ? e.message : t('common.error'))
);
},
};
}
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { useTranslation } from '../../i18n';
import { isApplePlatform } from '../../utils/applePlatform';
/** Whether a key press is the assistant's shortcut: ⌘K on a Mac, Ctrl+K elsewhere, either works on both. */
export function isAssistantHotkey(e: KeyboardEvent): boolean {
return (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k';
}
/**
* ⌘K / Ctrl+K opens or closes the assistant. A key press something else
* already took is left alone, and so is one while a dialog is open: the
* shortcut must not pull a window up behind a question being asked.
*/
export function useAssistantHotkey(enabled: boolean, onToggle: () => void): void {
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || !isAssistantHotkey(e)) return;
if (document.querySelector('.trek-modal-backdrop')) return;
e.preventDefault();
onToggle();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [enabled, onToggle]);
}
/** The shortcut as the keys a tooltip shows. */
export function useAssistantShortcutKeys(): string[] {
const { t } = useTranslation();
return isApplePlatform() ? ['⌘', 'K'] : [t('assistant.launcher.ctrlKey'), 'K'];
}

Some files were not shown because too many files have changed in this diff Show More