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.
This commit is contained in:
Maurice
2026-10-21 18:52:44 +02:00
parent bb3c0151d5
commit 214984f398
505 changed files with 51841 additions and 227 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",
+27
View File
@@ -22,6 +22,12 @@
"desktop": ["src/components/Admin/AddonManager.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
},
{
"feature": "Addon manager: AI assistant",
"hooks": ["src/components/Admin/addons/useAiAdminConfig.ts"],
"desktop": ["src/components/Admin/addons/AiAdminConfig.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminAiConfig.tsx"]
},
{
"feature": "Admin notification preference matrix",
"hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"],
@@ -58,6 +64,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 +673,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"],
-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",
+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,
+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({});
});
});
+186
View File
@@ -0,0 +1,186 @@
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: (): Promise<AiAuthStartResponse> => apiClient.post('/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;
}
@@ -17,6 +17,7 @@ import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
import { useAddonManager } from './addons/useAddonManager'
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
import AiAdminConfig from './addons/AiAdminConfig'
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
import WandererMark from '../Tours/wanderer/WandererMark'
@@ -182,6 +183,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
if (addon.id === 'llm_parsing' && addon.enabled) {
return <LlmParsingConfig addon={addon} />
}
// The assistant's settings and the connection everyone runs on, the same way.
if (addon.id === 'ai_assistant' && addon.enabled) {
return <AiAdminConfig />
}
return null
}
@@ -0,0 +1,100 @@
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-004: the AI assistant's form on its addon tile.
import userEvent from '@testing-library/user-event';
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
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(),
}));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
const { default: AiAdminConfig } = 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 renderConfig() {
return render(
<ul>
<AiAdminConfig />
</ul>
);
}
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('AiAdminConfig', () => {
it('FE-COMP-AI-ADMIN-001: shows who pays, the setup for everyone, the daily limit and the retention', async () => {
renderConfig();
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
expect(screen.queryByText('Sign in with ChatGPT')).not.toBeInTheDocument();
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
expect(screen.getByText('Keep chats')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
const user = userEvent.setup();
renderConfig();
await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' }));
await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument());
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => {
const user = userEvent.setup();
repo.adminConnection.mockResolvedValue(LIVE);
renderConfig();
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 });
renderConfig();
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);
});
});
@@ -0,0 +1,190 @@
import { Check, Loader2 } from 'lucide-react';
import React, { useId } from 'react';
import { useTranslation } from '../../../i18n';
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import ConfirmDialog from '../../shared/ConfirmDialog';
import CustomSelect from '../../shared/CustomSelect';
import { fs } from '../../shared/DialogShell';
import { NumericInput } from '../../shared/NumericInput';
import { EditorField, INPUT } from '../../shared/dialogParts';
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 AI assistant's settings on its addon tile, as a form in the shelf like
* AI parsing's: who pays, the connection for everyone, the daily limit while
* the instance pays, and how long chats are kept. Each change saves at once.
*/
export default function AiAdminConfig(): React.ReactElement {
const { t } = useTranslation();
const A = useAiAdminConfig();
const fieldId = useId();
if (!A.loaded) {
return (
<li className="grid place-items-center p-4">
<Loader2 size={18} className="animate-spin text-content-faint" />
</li>
);
}
return (
<li className="flex flex-col gap-4 p-3">
<PolicyChoice A={A} />
{A.instancePays && <InstanceConnection A={A} />}
{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>
<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
/>
</li>
);
}
@@ -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,218 @@
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,
};
}
@@ -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,348 @@
/**
* 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 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();
const addToast = vi.fn();
beforeEach(() => {
decide.mockResolvedValue(undefined);
useAssistantStore.setState({ decide, undo, undoBlock, setCompare, requestCamera, setMinimized });
window.__addToast = addToast as unknown as typeof window.__addToast;
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
useTripStore.setState(TRIP_INITIAL, true);
delete window.__addToast;
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();
expect(screen.getByText('Address')).toBeInTheDocument();
expect(screen.getByText('09:00')).toBeInTheDocument();
expect(screen.getByText('10:30')).toBeInTheDocument();
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.getByText('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.getByText('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.getByText('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*="py-2.5"]');
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 days a waiting card touches read as a band, with the schedule folded away', () => {
plannerHere();
renderCard([onTheMap('c1', 'Pantheon')]);
expect(screen.getByText('Rome')).toBeInTheDocument();
expect(screen.queryByText('New')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show schedule' }));
expect(screen.getByRole('button', { name: 'Hide schedule' })).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Louvre')).toBeInTheDocument();
expect(screen.getAllByText('Pantheon')).toHaveLength(2);
expect(screen.getByText('New')).toBeInTheDocument();
expect(screen.getByText(/min travel/)).toBeInTheDocument();
// Hovering the stop in the schedule lights the same pin as its row.
fireEvent.mouseEnter(screen.getByText('New'));
expect(useAssistantStore.getState().mapLink?.hoveredId).toBe('c1');
});
it('FE-AI-CARD-010: no band while the planner holds another trip, or once nothing waits', () => {
plannerHere(TRIP + 1);
const first = renderCard([onTheMap('c1', 'Pantheon')]);
expect(screen.queryByRole('button', { name: 'Show schedule' })).toBeNull();
first.unmount();
plannerHere();
renderCard([{ ...onTheMap('c1', 'Pantheon'), state: 'applied' }]);
expect(screen.queryByRole('button', { name: 'Show schedule' })).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,474 @@
import type { AssistantChangePreview, AssistantChangeRow, AssistantChangeVerbKey } from '@trek/shared';
import {
ArrowRight,
CalendarArrowUp,
CalendarMinus,
CalendarPlus,
Check,
CircleSlash,
ListTodo,
LoaderCircle,
Luggage,
MapPin,
MapPinPlus,
PencilLine,
StickyNote,
Ticket,
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 ToggleSwitch from '../Settings/ToggleSwitch';
import { Tooltip } from '../shared/Tooltip';
import { useToast } from '../shared/Toast';
import AssistantCompareSwitch from './AssistantCompareSwitch';
import AssistantDayBand from './AssistantDayBand';
import { CHANGE_LABELS, type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { changePoints, type DayChange } from './dayAfterModel';
import { useChangeHover } from './useChangeHover';
/**
* 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' },
addReservation: { Icon: Ticket, tone: 'success' },
importBooking: { Icon: Ticket, tone: 'success' },
};
/** 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 verb as a quiet line over the name of what it touches. */
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">
<span className="text-caption text-content-muted">{t(`assistant.change.verb.${preview.verbKey}`)}</span>
<bdi className={`truncate text-body font-semibold text-content ${struck ? 'line-through' : ''}`}>
{preview.subject}
</bdi>
</span>
);
}
/** One field as a badge that holds its label as a smaller badge; a change shows the old value struck through. */
function FieldBadge({ row }: { row: AssistantChangeRow }) {
const { t } = useTranslation();
const empty = <span className="italic text-content-faint">{t('assistant.change.empty')}</span>;
return (
<span className="inline-flex max-w-full items-center gap-1.5 rounded-full bg-surface-secondary py-[3px] pe-2.5 ps-[3px] text-caption text-content">
<span className="flex-shrink-0 rounded-full bg-surface-card px-2 py-px text-content-muted">
{t(CHANGE_LABELS[row.labelKey].labelKey)}
</span>
{row.op !== 'add' && (
<>
<bdi className="min-w-0 truncate text-content-faint line-through">{row.before || empty}</bdi>
<ArrowRight size={11} aria-hidden="true" className="flex-shrink-0 text-content-faint rtl:-scale-x-100" />
</>
)}
<bdi className="min-w-0 truncate">{row.after || empty}</bdi>
</span>
);
}
function ChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
if (rows.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-1.5">
{rows.map((row, i) => (
<FieldBadge key={`${row.labelKey}-${i}`} row={row} />
))}
</div>
);
}
/** 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 }]
: []
);
}
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 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 && 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; resting on it lights its pin on the map. */
function PendingRow({
item,
excluded,
withSwitch,
disabled,
onToggle,
}: {
item: ChangeItem;
excluded: boolean;
withSwitch: boolean;
disabled: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const { litFromMap, handlers } = useChangeHover(tripId, item.preview?.effect ? item.toolCallId : null);
return (
<div
{...handlers}
className={`-mx-1.5 flex gap-2.5 rounded-lg px-1.5 py-2.5 transition-[opacity,background-color] ${
excluded ? 'opacity-45' : ''
} ${litFromMap ? 'bg-surface-hover' : ''}`}
>
<KindTile verbKey={item.preview?.verbKey ?? null} />
<div className="min-w-0 flex-1">
<ChangeHead preview={item.preview} />
{item.preview && <ChangeFields rows={item.preview.rows} />}
</div>
{withSwitch && (
<ToggleSwitch on={!excluded} onToggle={onToggle} label={t('assistant.change.include')} disabled={disabled} />
)}
</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 toMap ? <ViewOnMapButton changes={changes} /> : null;
}
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 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 && <AssistantDayBand changes={changes} />}
<div className={open ? 'divide-y divide-edge-faint' : ''}>
{items.map((item) =>
item.state === 'pending' ? (
<PendingRow
key={item.toolCallId}
item={item}
excluded={excluded.has(item.toolCallId)}
withSwitch={pending.length > 1}
disabled={isRunning}
onToggle={() => toggle(item.toolCallId)}
/>
) : (
<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,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,163 @@
/**
* 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.findByPlaceholderText('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();
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"
/>
<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,186 @@
import { ChevronDown, TriangleAlert } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../i18n';
import { useTripStore } from '../../store/tripStore';
import { dayLabel } from '../../utils/dayLabel';
import { Tooltip } from '../shared/Tooltip';
import { useAssistantView } from './assistantViewContext';
import { buildDayAfter, changedDays, type DayAfter, type DayChange, type TimelineStop } from './dayAfterModel';
import { useChangeHover } from './useChangeHover';
/**
* The days a waiting card touches, as they would read once it ran: one thin
* band per day with a block per stop, so a glance shows where the suggestion
* lands among the rest. The details (times, the way between stops, free time,
* a clash) stay folded away until asked for. Two days at most; the rest are
* counted. Shown only while the planner holds this trip, since the day plans
* come from it.
*/
/** Days drawn as a band; the rest are counted. */
const MAX_DAYS = 2;
const SEGMENT: Record<TimelineStop['status'], string> = {
kept: 'bg-surface-secondary',
new: 'border border-dashed border-success bg-success-soft',
removed: 'border border-edge-faint opacity-50',
};
function Segment({ stop }: { stop: TimelineStop }) {
const { tripId } = useAssistantView();
const { lit, handlers } = useChangeHover(tripId, stop.toolCallId);
const clash = stop.lead?.overlapsWith ? 'ring-1 ring-inset ring-danger' : '';
return (
<Tooltip label={stop.name} placement="top">
<span
{...(stop.toolCallId ? handlers : {})}
className={`h-full min-w-[6px] flex-1 rounded-sm transition-shadow ${SEGMENT[stop.status]} ${clash} ${lit ? 'ring-2 ring-inset ring-accent' : ''}`}
/>
</Tooltip>
);
}
function Lead({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
const { t } = useTranslation();
const lead = stop.lead;
if (!lead) return null;
const travel = lead.travelMinutes !== null && lead.travelMinutes > 0;
if (!travel && lead.freeMinutes === null && !lead.overlapsWith) return null;
return (
<li
className={`flex flex-wrap items-center gap-x-2 text-caption text-content-faint ${timed ? 'ps-[3.25rem]' : 'ps-1'}`}
>
{travel && <span>{t('assistant.change.timeline.travel', { count: lead.travelMinutes ?? 0 })}</span>}
{lead.freeMinutes !== null && <span>{t('assistant.change.timeline.gap', { count: lead.freeMinutes })}</span>}
{lead.overlapsWith && (
<span className="inline-flex items-center gap-1 text-content-muted">
<TriangleAlert size={11} aria-hidden="true" className="flex-shrink-0 text-danger" />
<bdi>{t('assistant.change.timeline.overlap', { place: lead.overlapsWith })}</bdi>
</span>
)}
</li>
);
}
function StopLine({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const { lit, handlers } = useChangeHover(tripId, stop.toolCallId);
const removed = stop.status === 'removed';
return (
<li
{...(stop.toolCallId ? handlers : {})}
className={`flex items-center gap-2 rounded-md px-1 py-0.5 ${lit ? 'bg-surface-hover' : ''}`}
>
{timed && (
<span className="w-10 flex-shrink-0 text-caption tabular-nums text-content-faint">{stop.time ?? ''}</span>
)}
<bdi className={`min-w-0 truncate text-caption ${removed ? 'text-content-faint line-through' : 'text-content'}`}>
{stop.name}
</bdi>
{stop.status === 'new' && (
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-caption text-content-muted">
{t('assistant.change.timeline.new')}
</span>
)}
</li>
);
}
function DayRow({ day, label, open }: { day: DayAfter; label: string; open: boolean }) {
const timed = day.stops.some((s) => s.time !== null);
return (
<div>
<div className="flex items-center gap-2">
<bdi className="w-16 flex-shrink-0 truncate text-caption text-content-muted">{label}</bdi>
<div className="flex h-6 min-w-0 flex-1 gap-0.5 overflow-hidden" aria-hidden="true">
{day.stops.map((stop) => (
<Segment key={stop.key} stop={stop} />
))}
</div>
</div>
{open && (
<ol className="trek-assistant-enter mt-1 space-y-0.5" aria-label={label}>
{day.stops.map((stop) => (
<StopGroup key={stop.key} stop={stop} timed={timed} />
))}
</ol>
)}
</div>
);
}
function StopGroup({ stop, timed }: { stop: TimelineStop; timed: boolean }) {
return (
<>
<Lead stop={stop} timed={timed} />
<StopLine stop={stop} timed={timed} />
</>
);
}
export default function AssistantDayBand({ changes }: { changes: readonly DayChange[] }) {
const { t, locale } = useTranslation();
const { tripId } = useAssistantView();
const [open, setOpen] = useState(false);
const plan = useTripStore(
useShallow((s) => ({
here: s.trip?.id === tripId,
days: s.days,
assignments: s.assignments,
places: s.places,
}))
);
const rows = useMemo(() => {
if (!plan.here || changes.length === 0) return [];
return changedDays(changes).flatMap((dayId) => {
const index = plan.days.findIndex((d) => d.id === dayId);
const day = plan.days[index];
if (!day) return [];
const after = buildDayAfter({
dayId,
assignments: plan.assignments,
places: plan.places,
changes,
defaultMode: day.default_transport_mode,
});
return after.stops.length > 0 ? [{ after, label: dayLabel(day, index, t, locale) }] : [];
});
}, [plan, changes, t, locale]);
if (rows.length === 0) return null;
const shown = rows.slice(0, MAX_DAYS);
const toggleLabel = t(open ? 'assistant.change.timeline.hide' : 'assistant.change.timeline.show');
return (
<div className="mb-1 flex gap-1.5">
<div className="min-w-0 flex-1 space-y-1.5">
{shown.map(({ after, label }) => (
<DayRow key={after.dayId} day={after} label={label} open={open} />
))}
{rows.length > MAX_DAYS && (
<p className="ps-[4.5rem] text-caption text-content-faint">
{t('assistant.change.timeline.moreDays', { count: rows.length - MAX_DAYS })}
</p>
)}
</div>
<Tooltip label={toggleLabel} placement="top">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={toggleLabel}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
<ChevronDown
size={14}
aria-hidden="true"
className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
/>
</button>
</Tooltip>
</div>
);
}
@@ -0,0 +1,128 @@
/**
* 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,183 @@
import { Ellipsis, History, Lightbulb, LightbulbOff, Plus, Settings, Trash2, X } from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n';
import { PopoverItem } from '../Packing/PackingPopover';
import ConfirmDialog from '../shared/ConfirmDialog';
import { NEUTRAL_TINT } from '../shared/DialogShell';
import { ContextMenu, useContextMenu } from '../shared/ContextMenu';
import Tooltip from '../shared/Tooltip';
import { PopoverListPanel } from '../shared/dialogParts';
import { usePopoverList } from '../shared/usePopoverList';
import { AI_SETTINGS_PATH } from './assistantParts';
import { useAssistantHintsSetting } from './useAssistantHints';
import type { useAssistantThread } from './useAssistantThread';
// 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 {
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({ thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
const { t, locale } = useTranslation();
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 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">
<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('assistant.history.open')}>
<button
ref={history.triggerRef}
type="button"
onClick={openHistory}
aria-label={t('assistant.history.open')}
aria-expanded={history.open}
aria-controls={history.open ? history.listId : 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} />
<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 [@media(hover:hover)]:opacity-0 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100'
}`}
>
<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,101 @@
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,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,26 @@
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,158 @@
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 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} />}
</MessagePrimitive.Root>
);
}
export default function AssistantMessage({ message }: { message: StoredMessage }) {
return message.role === 'user' ? <Question message={message} /> : <Answer message={message} />;
}
@@ -0,0 +1,204 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { Check, ChevronDown, LoaderCircle, MapPin, Plus } from 'lucide-react';
import { type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { PopoverItem } from '../Packing/PackingPopover';
import { getCategoryIcon } from '../shared/categoryIcons';
import { PopoverListPanel } from '../shared/dialogParts';
import { Tooltip } from '../shared/Tooltip';
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.min(320, days.length * 34 + 8),
});
const height = tall ? 'h-10' : 'h-7';
const busy = adding === place.ref;
const pick = (dayId: number) => {
list.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={list.triggerRef}
type="button"
disabled={disabled}
onClick={list.toggle}
aria-expanded={list.open}
aria-controls={list.open ? list.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={list.triggerRef}
type="button"
disabled={disabled}
onClick={list.toggle}
aria-expanded={list.open}
aria-controls={list.open ? list.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
)}
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="max-w-[16rem]">
{days.map((day) => (
<PopoverItem
key={day.id}
icon={null}
label={day.label}
active={day.id === focusDay?.id}
onClick={() => pick(day.id)}
/>
))}
</PopoverListPanel>
</>
);
}
/**
* One place a search found, as a small card: its picture from TREK's photo
* proxy (or its category's icon), its name, the category it most likely
* belongs in, whether it is open now while the trip runs, how far it is from
* the last stop of the day in focus, and one way on: "+ Day 3" for a new
* 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 Icon = category ? getCategoryIcon(category.icon) : MapPin;
const tall = variant === 'fullscreen';
let action: ReactNode = null;
if (place.inTripPlaceId !== null) {
action = (
<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>
);
} else if (suggested) {
action = (
<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>
);
} else if (strip.canSuggest) {
action = <SuggestButton place={place} strip={strip} tall={tall} />;
}
return (
<li
ref={ref}
className={`flex flex-shrink-0 snap-start flex-col overflow-hidden rounded-xl border border-edge-faint bg-surface-card ${
density === 'compact' ? 'w-[168px]' : 'w-[200px]'
}`}
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"
>
<span className="flex h-[72px] w-full items-center justify-center overflow-hidden bg-surface-secondary">
{photo ? (
<img src={photo} alt="" decoding="async" loading="lazy" className="h-full w-full object-cover" />
) : (
// The category's own colour, data from the trip; only the small icon wears it.
<Icon size={20} aria-hidden="true" style={{ color: category?.color ?? 'var(--text-faint)' }} />
)}
</span>
<span className="flex min-w-0 flex-col gap-0.5 px-2.5 pt-2">
<bdi className="truncate text-body font-semibold text-content">{place.name}</bdi>
{(category || place.openNow !== null) && (
<span className="flex min-w-0 items-center gap-2 text-caption text-content-muted">
{category && <bdi className="min-w-0 truncate">{category.name}</bdi>}
{place.openNow === true && (
<span className="inline-flex flex-shrink-0 items-center gap-1">
<span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-success" />
{t('assistant.place.openNow')}
</span>
)}
{place.openNow === false && <span className="flex-shrink-0">{t('assistant.place.closed')}</span>}
</span>
)}
{distance && (
<span className="truncate text-caption text-content-faint">
{t('assistant.place.fromPrev', { distance })}
</span>
)}
</span>
</button>
<div className="mt-auto flex min-w-0 px-2.5 pb-2.5 pt-2">{action}</div>
</li>
);
}
@@ -0,0 +1,228 @@
/**
* 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 { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import {
buildAssignment,
buildCategory,
buildDay,
buildPlace,
buildTrip,
buildUser,
} from '../../../tests/helpers/factories';
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 six cards and counts the rest on a tile', () => {
seed();
const list = Array.from({ length: 8 }, (_, i) => place(`s1:${i}`));
renderStrip({ toolCallId: 's1', places: list, more: 4 });
expect(screen.getAllByRole('button', { name: /^Add to/ })).toHaveLength(6);
expect(screen.getByText('6 more')).toBeInTheDocument();
});
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,112 @@
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; the rest are counted on the tile at its end. */
const SHOWN = 6;
const ARROW =
'absolute top-[52px] z-10 hidden h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full border border-edge-faint bg-surface-card text-content-secondary shadow-dropdown hover:text-content group-hover:flex';
/** 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 two arrows show on hover while the row
* overflows. At most six cards, then a tile counting the rest.
*/
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 most of a view towards the start (-1) or the end (1) of the row. */
const page = (towards: -1 | 1) => {
const el = listRef.current;
if (!el) return;
const rtl = getComputedStyle(el).direction === 'rtl';
el.scrollBy((rtl ? -towards : towards) * el.clientWidth * 0.8, 0);
};
return (
<section aria-label={t('assistant.place.strip')} className="group relative -mx-4">
<ul
ref={listRef}
onScroll={measure}
data-fade-start={fade.start || undefined}
data-fade-end={fade.end || undefined}
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2 overflow-x-auto px-4 pb-1"
>
{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-xl border border-dashed border-edge-faint px-2 text-center text-caption text-content-muted">
{t('assistant.place.more', { count: rest })}
</li>
)}
</ul>
{variant === 'window' && (
// The cards are reachable by Tab; the arrows are for the pointer only.
<>
{fade.start && (
<button
type="button"
tabIndex={-1}
aria-hidden="true"
onClick={() => page(-1)}
className={`${ARROW} start-2`}
>
<ChevronLeft size={15} className="rtl:-scale-x-100" />
</button>
)}
{fade.end && (
<button type="button" tabIndex={-1} aria-hidden="true" onClick={() => page(1)} className={`${ARROW} end-2`}>
<ChevronRight size={15} className="rtl:-scale-x-100" />
</button>
)}
</>
)}
</section>
);
}
@@ -0,0 +1,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.findByPlaceholderText('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.findByPlaceholderText('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,118 @@
import type { AssistantWeatherDay } from '@trek/shared';
import { Droplet } from 'lucide-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>
<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,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,184 @@
/**
* 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 { 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) {
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);
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,294 @@
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
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);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useAssistantHotkey(available, toggle);
// A trip picked outside the planner is only for this opening; the next one asks again.
useEffect(() => {
if (!open) setPicked(null);
}, [open]);
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,257 @@
/* 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;
}
@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 {
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 {
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,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,491 @@
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 = 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,161 @@
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,275 @@
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;
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' },
},
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,324 @@
/**
* 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: shows only the newest search, and steps after it join the row before it', () => {
const blocks = messageBlocks(
message([
read('s1', []),
places('s1', [found('s1:0')]),
read('s2', []),
places('s2', [found('s2:0'), found('s2:1', { lat: 40 })], 3),
{ type: 'text', text: 'Two good ones.' },
]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'places', 'text']);
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
expect(blocks[1]).toMatchObject({
found: { toolCallId: 's2', more: 3, places: [{ ref: 's2:0' }, { ref: 's2:1' }] },
});
});
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,248 @@
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 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.
*
* Only the newest search shows its places, 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) => {
const last = blocks[blocks.length - 1];
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 places = part.data.places.filter((place) => !proposed(place, effects));
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...part.data, 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) };
// 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.
const lastId = last?.kind === 'change' ? last.items[last.items.length - 1]?.toolCallId : undefined;
const apart = fromStrip.has(part.toolCallId) || (lastId !== undefined && fromStrip.has(lastId));
if (last?.kind === 'change' && !apart) last.items.push(item);
else blocks.push({ kind: 'change', key: `change-${part.toolCallId}`, items: [item] });
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 }> = {
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,107 @@
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,142 @@
/**
* FE-AI-DAYAFTER-001 to FE-AI-DAYAFTER-008
*
* 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 });
});
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 });
});
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],
]);
});
});
@@ -0,0 +1,232 @@
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;
}
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;
if (cur.start === null || prev.end === null) return { travelMinutes, freeMinutes: null, overlapsWith: null };
if (cur.start < prev.end) return { travelMinutes, freeMinutes: null, overlapsWith: prev.stop.name };
const free = cur.start - prev.end - (travelMinutes ?? 0);
return { travelMinutes, freeMinutes: free >= GAP_MIN_MINUTES ? free : null, overlapsWith: 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. */
export 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 '../shared/Kbd';
/** 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'];
}
@@ -0,0 +1,41 @@
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import { prefersReducedMotion } from '../../utils/reducedMotion';
import { liveStepText } from './assistantModel';
interface LiveStep {
/** The call the line speaks of. */
toolCallId: string;
/** "Searching near Trastevere": what the running step does, and where. */
text: string;
/**
* The step searches an area and the map is set to follow it there (the
* setting is on and motion is not reduced), so the line may say so.
*/
hasArea: boolean;
}
/**
* What the assistant is doing right now, in words, once a running step said
* where it looks: the line under the steps and the phone's pill read it. Null
* while no step names a place or a query. The step's name comes from the call
* in the chat; the place from the stream, which never stores it.
*/
export function useAssistantLiveStep(tripId: number): LiveStep | null {
const { t } = useTranslation();
const follow = useSettingsStore((s) => s.settings.assistant_map_follow) !== false;
const activity = useAssistantStore((s) => chatOf(s, tripId).live.activity);
const toolName = useAssistantStore((s) => {
const id = chatOf(s, tripId).live.activity?.toolCallId;
if (!id) return null;
const messages = chatOf(s, tripId).messages;
const last = messages[messages.length - 1];
const call = last?.parts.find((p) => p.type === 'tool-call' && p.toolCallId === id);
return call?.type === 'tool-call' ? call.toolName : null;
});
if (!activity || !toolName || !activity.label) return null;
const { key, params } = liveStepText(toolName, activity.label);
const hasArea = activity.area !== null && follow && !prefersReducedMotion();
return { toolCallId: activity.toolCallId, text: t(key, params), hasArea };
}
@@ -0,0 +1,25 @@
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useToast } from '../shared/Toast';
/**
* Whether the trip map follows the assistant: pans to where it searches and
* frames the places it suggests. On unless switched off. Behind the switch in
* both AI settings shells, the desktop section and the phone card.
*/
export function useAssistantMapFollow(): { on: boolean; set: (on: boolean) => Promise<void> } {
const on = useSettingsStore((s) => s.settings.assistant_map_follow) !== false;
const updateSetting = useSettingsStore((s) => s.updateSetting);
const { t } = useTranslation();
const toast = useToast();
const set = async (next: boolean) => {
try {
await updateSetting('assistant_map_follow', next);
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('common.error'));
}
};
return { on, set };
}
@@ -0,0 +1,82 @@
import { type TriggerAdapter, type TriggerItem, useAui } from '@assistant-ui/react';
import { ASSISTANT_MENTION_KINDS } from '@trek/shared';
import { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { type AssistantMentionItem, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import { MENTION_KINDS, mentionGroups, mentionOf, searchMentions, withMention } from './assistantMentionModel';
/**
* Writes the composer's text and keeps the draft in step: a text set from
* code fires no change event, so the store would otherwise keep the old one.
*/
function useComposerWriter(tripId: number) {
const aui = useAui();
const setDraft = useAssistantStore((s) => s.setDraft);
const read = () => aui.composer.getState().text;
return {
read,
/** Takes into the draft what the popover wrote into the text itself. */
keep: () => setDraft(tripId, read()),
write: (text: string) => {
aui.composer.setText(text);
setDraft(tripId, text);
},
};
}
/**
* `@` in the composer: the trip's days, places and bookings to pick from
* (nothing for a trip the planner is not showing, whose lists are not at
* hand). The text is the truth: a pick stays as `@label` in it, and a
* mention whose `@label` was deleted is not sent.
*/
export function useAssistantMentions(tripId: number) {
const { t, locale } = useTranslation();
const here = useTripStore((s) => s.trip?.id === tripId);
const days = useTripStore((s) => s.days);
const places = useTripStore((s) => s.places);
const reservations = useTripStore((s) => s.reservations);
const addMention = useAssistantStore((s) => s.addMention);
const composer = useComposerWriter(tripId);
const adapter = useMemo((): TriggerAdapter | undefined => {
if (!here) return undefined;
const groups = mentionGroups({ days, places, reservations }, t, locale);
const kinds = ASSISTANT_MENTION_KINDS.filter((k) => groups[k].length > 0);
const categories = kinds.map((k) => ({ id: k, label: t(MENTION_KINDS[k].labelKey) }));
return {
categories: () => categories,
categoryItems: (id) => {
const kind = ASSISTANT_MENTION_KINDS.find((k) => k === id);
return kind ? groups[kind] : [];
},
search: (query) => searchMentions(groups, query),
};
}, [here, days, places, reservations, t, locale]);
return {
adapter,
/** After the popover wrote `@label` into the text. */
onInserted: (item: TriggerItem) => {
const mention = mentionOf(item);
if (mention) addMention(tripId, mention);
composer.keep();
},
};
}
/**
* Names something in the question from outside the text, such as a place
* dropped on the chat: `@label` goes at the end of what is typed, the way a
* pick from the popover leaves it, once.
*/
export function useInsertMention(tripId: number) {
const addMention = useAssistantStore((s) => s.addMention);
const composer = useComposerWriter(tripId);
return (mention: AssistantMentionItem) => {
addMention(tripId, mention);
composer.write(withMention(composer.read(), mention.label));
};
}
@@ -0,0 +1,129 @@
/**
* FE-AI-PLACEDROP-001 to FE-AI-PLACEDROP-005
*
* A place dragged from the sidebar or the map onto the chat goes into the
* question as `@Name`, beside the booking file drop on the same chat: each
* drop zone takes only its own kind of drag.
*/
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { buildPlace, buildTrip } from '../../../tests/helpers/factories';
import { act, 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 attach = vi.fn(async () => {});
function seed(tripInPlanner = TRIP) {
useAssistantStore.setState({
status: STATUS,
attach,
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages: [] } },
});
useTripStore.setState({
trip: buildTrip({ id: tripInPlanner, title: 'Paris' }),
places: [buildPlace({ id: 21, name: 'Louvre' })],
});
}
async function input(): Promise<HTMLTextAreaElement> {
render(<AssistantSurface tripId={TRIP} tripTitle="Paris" variant="window" />);
return (await screen.findByPlaceholderText('Ask anything about Paris')) as HTMLTextAreaElement;
}
/** A drag as startPlaceDrag starts it; jsdom keeps the type's case, browsers lower it. */
const placeDrag = (id: string) => ({ dataTransfer: { types: ['placeId'], getData: () => id } });
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;
});
afterEach(() => {
attach.mockClear();
useAssistantStore.setState(INITIAL, true);
useTripStore.setState(INITIAL_TRIP, true);
});
describe('useAssistantPlaceDrop', () => {
it('FE-AI-PLACEDROP-001: a dragged place lights the chat with its own hint', async () => {
seed();
const field = await input();
fireEvent.dragEnter(field, placeDrag('21'));
expect(screen.getByText('Drop the place here')).toBeInTheDocument();
expect(screen.queryByText('Drop it here and I will read the booking')).toBeNull();
fireEvent.dragLeave(field, { relatedTarget: document.body });
expect(screen.queryByText('Drop the place here')).toBeNull();
});
it('FE-AI-PLACEDROP-002: dropping a place writes @Name after the text and keeps the mention', async () => {
seed();
const field = await input();
fireEvent.change(field, { target: { value: 'Lunch near' } });
fireEvent.dragEnter(field, placeDrag('21'));
fireEvent.drop(field, placeDrag('21'));
await waitFor(() => expect(field.value).toBe('Lunch near @Louvre '));
const chat = chatOf(useAssistantStore.getState(), TRIP);
expect(chat.mentions).toEqual([{ kind: 'place', id: 21, label: 'Louvre' }]);
expect(chat.draft).toBe('Lunch near @Louvre ');
expect(screen.queryByText('Drop the place here')).toBeNull();
expect(document.activeElement).toBe(field);
});
it('FE-AI-PLACEDROP-003: the same place dropped twice is named once', async () => {
seed();
const field = await input();
fireEvent.drop(field, placeDrag('21'));
fireEvent.drop(field, placeDrag('21'));
await waitFor(() => expect(field.value).toBe('@Louvre '));
expect(chatOf(useAssistantStore.getState(), TRIP).mentions).toHaveLength(1);
});
it('FE-AI-PLACEDROP-004: ignores a place the trip lacks and a trip the planner does not show', async () => {
seed();
const field = await input();
fireEvent.drop(field, placeDrag('999'));
expect(field.value).toBe('');
act(() => useTripStore.setState({ trip: buildTrip({ id: 99 }) }));
fireEvent.dragEnter(field, placeDrag('21'));
expect(screen.queryByText('Drop the place here')).toBeNull();
fireEvent.drop(field, placeDrag('21'));
expect(field.value).toBe('');
expect(chatOf(useAssistantStore.getState(), TRIP).mentions).toEqual([]);
});
it('FE-AI-PLACEDROP-005: a booking file still goes to the import, not into the text', async () => {
seed();
const field = await input();
const file = new File(['BEGIN:VCALENDAR'], 'hotel.ics', { type: 'text/calendar' });
fireEvent.dragEnter(field, { dataTransfer: { types: ['Files'], files: [file] } });
expect(screen.getByText('Drop it here and I will read the booking')).toBeInTheDocument();
expect(screen.queryByText('Drop the place here')).toBeNull();
fireEvent.drop(field, { dataTransfer: { types: ['Files'], files: [file], getData: () => '' } });
await waitFor(() => expect(attach).toHaveBeenCalledWith(TRIP, file));
expect(field.value).toBe('');
});
});
@@ -0,0 +1,73 @@
import { type DragEvent, useState } from 'react';
import { useTripStore } from '../../store/tripStore';
import { mentionLabel } from './assistantMentionModel';
import { useInsertMention } from './useAssistantMentions';
/** The type `startPlaceDrag` writes; browsers list data types in lower case. */
const PLACE_TYPE = 'placeid';
function carriesPlace(e: DragEvent): boolean {
return Array.from(e.dataTransfer.types).some((type) => type.toLowerCase() === PLACE_TYPE);
}
interface DropZone {
onDragEnter: (e: DragEvent) => void;
onDragOver: (e: DragEvent) => void;
onDragLeave: (e: DragEvent) => void;
onDrop: (e: DragEvent) => void;
}
/**
* A place dragged from the sidebar or the map onto the chat goes into the
* question as `@Name`, like a pick from the `@` list. Only a place of the trip
* the planner shows is taken; every other drag (a booking file above all) is
* left to the other handlers on the same chat. `dragging` lights the drop zone.
*/
export function useAssistantPlaceDrop(tripId: number): { dragging: boolean; dropZone: DropZone } {
const here = useTripStore((s) => s.trip?.id === tripId);
const insert = useInsertMention(tripId);
const [dragging, setDragging] = useState(false);
const takes = (e: DragEvent) => here && carriesPlace(e);
const dropZone: DropZone = {
onDragEnter: (e) => {
if (!takes(e)) return;
e.preventDefault();
setDragging(true);
},
onDragOver: (e) => {
if (!takes(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
},
onDragLeave: (e) => {
// Leaving for a child is not leaving the zone.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setDragging(false);
},
onDrop: (e) => {
if (!takes(e)) return;
e.preventDefault();
setDragging(false);
const id = Number(e.dataTransfer.getData('placeId'));
const place = useTripStore.getState().places.find((p) => p.id === id);
const label = place ? mentionLabel(place.name) : '';
if (!place || !label) return;
insert({ kind: 'place', id: place.id, label });
e.currentTarget.querySelector('textarea')?.focus();
},
};
return { dragging, dropZone };
}
/** Several drop handlers on one element; each one looks only at the drags it takes. */
export function joinDropZones(...zones: DropZone[]): DropZone {
return {
onDragEnter: (e) => zones.forEach((z) => z.onDragEnter(e)),
onDragOver: (e) => zones.forEach((z) => z.onDragOver(e)),
onDragLeave: (e) => zones.forEach((z) => z.onDragLeave(e)),
onDrop: (e) => zones.forEach((z) => z.onDrop(e)),
};
}
@@ -0,0 +1,154 @@
import { type AssistantFoundPlace, categoryIdForKind } from '@trek/shared';
import { useCallback, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useCanDo } from '../../store/permissionsStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import type { Assignment } from '../../types';
import { dayLabel } from '../../utils/dayLabel';
import { haversineKm } from '../../utils/geo';
import { safeHexColor } from '../../utils/safeColor';
import { formatDistance } from '../../utils/units';
import { hasUndecided } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
/** A day a found place can be suggested for, named the way the day list names it. */
interface StripDay {
id: number;
/** Its place in the trip, 1 for the first day: "+ Day 3". */
n: number;
label: string;
}
/** The trip category a found place most likely belongs in, for its line and its icon. */
interface StripCategory {
name: string;
icon: string;
color: string;
}
/** The last stop of a day that has a spot on the map. */
function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng: number } | null {
const sorted = [...(stops ?? [])].sort((a, b) => a.order_index - b.order_index);
for (let i = sorted.length - 1; i >= 0; i--) {
const place = sorted[i]?.place;
if (place && typeof place.lat === 'number' && typeof place.lng === 'number') {
return { lat: place.lat, lng: place.lng };
}
}
return null;
}
/**
* What the strip of found places needs, the same for the window and the
* phone: the trip's days to suggest a place for, the day in focus, how far a
* place is from that day's last stop, its likely category, whether a
* suggestion can be made now and why not, and the map's part (the pin while
* the pointer rests on a card, the camera on a click).
*
* Suggesting writes nothing: the server holds the place as a change card to
* decide like any other. It waits while another change is undecided, because
* one decision answers all open cards.
*/
export function useAssistantPlaceStrip() {
const { t, locale } = useTranslation();
const { tripId, onRevealMap } = useAssistantView();
const { offline } = useNetworkMode();
const can = useCanDo();
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
const { trip, days, assignments, categories } = useTripStore(
useShallow((s) => {
const here = s.trip?.id === tripId;
return {
trip: here ? s.trip : null,
days: here ? s.days : null,
assignments: here ? s.assignments : null,
categories: s.categories,
};
})
);
const { focusDayId, isRunning, threadId, waiting } = useAssistantStore(
useShallow((s) => {
const chat = chatOf(s, tripId);
return {
focusDayId: chat.focus?.dayId ?? null,
isRunning: chat.isRunning,
threadId: chat.threadId,
waiting: hasUndecided(chat.messages),
};
})
);
const addFoundPlace = useAssistantStore((s) => s.addFoundPlace);
const requestCamera = useAssistantStore((s) => s.requestCamera);
const setPreviewPin = useAssistantStore((s) => s.setPreviewPin);
const [adding, setAdding] = useState<string | null>(null);
const stripDays = useMemo(
(): StripDay[] =>
(days ?? []).map((day, index) => ({ id: day.id, n: index + 1, label: dayLabel(day, index, t, locale) })),
[days, t, locale]
);
const focusDay = stripDays.find((d) => d.id === focusDayId) ?? null;
const from = focusDay && assignments ? lastSpot(assignments[String(focusDay.id)]) : null;
/** Whether "+ Day" shows at all: the planner holds the trip and the user may add to it. */
const canSuggest = !!trip && can('place_edit', trip) && stripDays.length > 0;
/** Why "+ Day" is off right now, as a tooltip; null when it is on. */
let blockedKey: string | null = null;
if (offline) blockedKey = 'assistant.error.offline';
else if (waiting) blockedKey = 'assistant.composer.decideFirst';
const disabled = blockedKey !== null || isRunning || threadId === null || adding !== null;
const categoryOf = (place: AssistantFoundPlace): StripCategory | null => {
const id = categoryIdForKind(place.kind, categories);
const category = id === null ? undefined : categories.find((c) => c.id === id);
if (!category) return null;
return { name: category.name, icon: category.icon, color: safeHexColor(category.color, 'var(--text-faint)') };
};
const distanceOf = (place: AssistantFoundPlace): string | null =>
from ? formatDistance(haversineKm(from, place), unit) : null;
const suggest = async (place: AssistantFoundPlace, dayId: number) => {
if (disabled) return;
setAdding(place.ref);
try {
await addFoundPlace(tripId, place.ref, dayId);
} finally {
setAdding(null);
}
};
/** Shows the place on the map; on the phone the chat steps aside first. */
const show = (place: AssistantFoundPlace) => {
onRevealMap?.();
requestCamera(tripId, [[place.lat, place.lng]], 'pan', 'user');
};
// Stable, so the strip can take the pin away when it goes.
const hover = useCallback(
(place: AssistantFoundPlace | null) => {
if (place) setPreviewPin({ tripId, ref: place.ref, lat: place.lat, lng: place.lng, name: place.name });
else setPreviewPin(null);
},
[tripId, setPreviewPin]
);
return {
days: stripDays,
focusDay,
canSuggest,
disabled,
blockedKey,
adding,
categoryOf,
distanceOf,
suggest,
show,
hover,
};
}
@@ -0,0 +1,44 @@
import { type AppendMessage, useExternalStoreRuntime } from '@assistant-ui/react';
import type { AssistantMessage } from '@trek/shared';
import { useMemo } from 'react';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { pendingToolCallIds } from '../../store/assistantMessages';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { toThreadMessage } from './assistantModel';
function textOf(message: AppendMessage): string {
const texts: string[] = [];
for (const part of message.content) if (part.type === 'text') texts.push(part.text);
return texts.join('\n');
}
/**
* The one place the chat runtime is built. The assistant store stays the
* source of truth: the runtime reads its messages through `toThreadMessage`
* and hands sending and stopping back to it. There is no editing, branching
* or reloading, so the thread stays the line the server keeps.
*
* Offline the composer is off; while a change waits for a decision, typing
* works but sending waits, because the model is still waiting for that answer.
*/
export function useAssistantRuntime(tripId: number) {
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
const isLoading = useAssistantStore((s) => chatOf(s, tripId).loading);
const send = useAssistantStore((s) => s.send);
const cancel = useAssistantStore((s) => s.cancel);
const { offline } = useNetworkMode();
const waiting = useMemo(() => pendingToolCallIds(messages).length > 0, [messages]);
return useExternalStoreRuntime<AssistantMessage>({
messages,
convertMessage: toThreadMessage,
isRunning,
isLoading,
isDisabled: offline,
isSendDisabled: waiting,
onNew: (message) => send(tripId, textOf(message)),
onCancel: async () => cancel(tripId),
});
}
@@ -0,0 +1,201 @@
/**
* FE-AI-SHARE-HOOK-001 to FE-AI-SHARE-HOOK-006
*
* Sharing an answer in the trip chat: who may, which answers can go, the
* preview and its switch, and what the user hears after the post or when it
* fails.
*/
import type { AssistantMessage } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTrip, buildUser } from '../../../tests/helpers/factories';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { assistantRepo } from '../../repo/assistantRepo';
import { OnlineOnlyError } from '../../repo/onlineOnly';
import { useAddonStore } from '../../store/addonStore';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { useTripStore } from '../../store/tripStore';
import AssistantShareDialog from './AssistantShareDialog';
import { useAssistantShare } from './useAssistantShare';
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
const AUTH = useAuthStore.getState();
const ADDONS = useAddonStore.getState();
const PERMISSIONS = usePermissionsStore.getState();
function answer(extra: Partial<AssistantMessage> = {}): AssistantMessage {
return {
id: 'msg-12',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-21T08:00:00.000Z',
parts: [
{ type: 'text', text: 'Take the **early** train.' },
{
type: 'tool-call',
toolCallId: 'c1',
toolName: 'create_place',
args: {},
approval: { id: 'c1', approved: true },
},
{
type: 'data-trek-change',
data: {
toolCallId: 'c1',
verbKey: 'addPlace',
subject: 'Nara Park',
rows: [],
dayId: 3,
placeIds: [],
undoable: true,
},
},
{ type: 'data-trek-applied', data: { toolCallId: 'c1', entity: 'place', ids: [9], dayId: 3, undoUntil: null } },
],
...extra,
};
}
function Harness({ message, writing = false }: { message: AssistantMessage; writing?: boolean }) {
const share = useAssistantShare(TRIP, message, writing);
if (!share.canShare) return <p>cannot share</p>;
return (
<>
<button type="button" onClick={share.open} disabled={share.offline}>
{share.shared ? 'shared' : 'share'}
</button>
<AssistantShareDialog share={share} />
</>
);
}
const addToast = vi.fn();
function seed({ chat = true, threadId = 7 as number | null } = {}) {
useAuthStore.setState({ user: buildUser({ id: 1 }) });
useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 1 }) });
const collab = { id: 'collab', name: 'Collab', type: 'trip', icon: '', enabled: true };
useAddonStore.setState({ addons: chat ? [collab] : [] });
useAssistantStore.setState({
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId } },
});
}
beforeEach(() => {
window.__addToast = addToast as unknown as typeof window.__addToast;
});
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useTripStore.setState(TRIPS, true);
useAuthStore.setState(AUTH, true);
useAddonStore.setState(ADDONS, true);
usePermissionsStore.setState(PERMISSIONS, true);
delete window.__addToast;
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('useAssistantShare', () => {
it('FE-AI-SHARE-HOOK-001: offers sharing only for a finished, stored answer with text', () => {
seed();
const { unmount } = render(<Harness message={answer()} />);
expect(screen.getByRole('button', { name: 'share' })).toBeInTheDocument();
unmount();
for (const message of [
answer({ id: 'local-1' }),
answer({ status: 'aborted' }),
answer({ parts: [{ type: 'text', text: ' ' }] }),
]) {
const view = render(<Harness message={message} />);
expect(screen.getByText('cannot share')).toBeInTheDocument();
view.unmount();
}
const view = render(<Harness message={answer()} writing />);
expect(screen.getByText('cannot share')).toBeInTheDocument();
view.unmount();
});
it('FE-AI-SHARE-HOOK-002: needs the chat add-on, a known thread and the right to post', () => {
seed({ chat: false });
const first = render(<Harness message={answer()} />);
expect(screen.getByText('cannot share')).toBeInTheDocument();
first.unmount();
// The add-on is on, but the admin switched its chat off.
seed();
useAddonStore.setState({ collabChat: false });
const chatOff = render(<Harness message={answer()} />);
expect(screen.getByText('cannot share')).toBeInTheDocument();
chatOff.unmount();
useAddonStore.setState({ collabChat: true });
seed({ threadId: null });
const second = render(<Harness message={answer()} />);
expect(screen.getByText('cannot share')).toBeInTheDocument();
second.unmount();
seed();
usePermissionsStore.setState({ permissions: { collab_edit: 'admin' } });
render(<Harness message={answer()} />);
expect(screen.getByText('cannot share')).toBeInTheDocument();
});
it('FE-AI-SHARE-HOOK-003: the preview shows the plain text, and the switch takes the changes out', () => {
seed();
render(<Harness message={answer()} />);
fireEvent.click(screen.getByRole('button', { name: 'share' }));
expect(screen.getByText(/Take the early train\.\s+Applied:\s+✓ Add place: Nara Park/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add the changes you applied' }));
expect(screen.getByText('Take the early train.')).toBeInTheDocument();
});
it('FE-AI-SHARE-HOOK-004: "Share" posts the previewed text once and says it went', async () => {
seed();
const share = vi.spyOn(assistantRepo, 'share').mockResolvedValue({} as never);
render(<Harness message={answer()} />);
fireEvent.click(screen.getByRole('button', { name: 'share' }));
fireEvent.click(screen.getByRole('button', { name: 'Share' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'shared' })).toBeInTheDocument());
expect(share).toHaveBeenCalledTimes(1);
expect(share).toHaveBeenCalledWith(TRIP, 7, 'msg-12', {
text: 'Take the early train.\n\nApplied:\n✓ Add place: Nara Park',
});
expect(addToast).toHaveBeenCalledWith('Shared in the trip chat', 'success', undefined);
});
it('FE-AI-SHARE-HOOK-005: a chat switched off and a failed post each say what happened', async () => {
seed();
const share = vi
.spyOn(assistantRepo, 'share')
.mockRejectedValueOnce({ response: { status: 403, data: { error: 'Collab chat is off', code: 'forbidden' } } })
.mockRejectedValueOnce(new Error('boom'))
.mockRejectedValueOnce(new OnlineOnlyError());
render(<Harness message={answer()} />);
for (const expected of [
'The trip chat is turned off.',
'Could not share this. Please try again in a moment.',
'Offline. The assistant needs internet.',
]) {
fireEvent.click(screen.getByRole('button', { name: 'share' }));
fireEvent.click(screen.getByRole('button', { name: 'Share' }));
await waitFor(() => expect(addToast).toHaveBeenLastCalledWith(expected, 'error', undefined));
}
expect(share).toHaveBeenCalledTimes(3);
expect(screen.getByRole('button', { name: 'share' })).toBeInTheDocument();
});
it('FE-AI-SHARE-HOOK-006: a long answer is cut, and the preview says so', () => {
seed();
const long = Array.from({ length: 1200 }, (_, i) => `word${i}`).join(' ');
render(<Harness message={answer({ parts: [{ type: 'text', text: long }] })} />);
fireEvent.click(screen.getByRole('button', { name: 'share' }));
expect(screen.getByText('Shortened to 4800 characters')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Add the changes you applied' })).toBeNull();
});
});
@@ -0,0 +1,87 @@
import type { AssistantMessage } from '@trek/shared';
import { useMemo, useState } from 'react';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { useTranslation } from '../../i18n';
import { assistantRepo } from '../../repo/assistantRepo';
import { OnlineOnlyError } from '../../repo/onlineOnly';
import { useAddonStore } from '../../store/addonStore';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useCanDo } from '../../store/permissionsStore';
import { useTripStore } from '../../store/tripStore';
import { useToast } from '../shared/Toast';
import { chatTextOf } from './assistantShare';
/** An answer the server stored, by the id it gives it on the wire. */
const STORED_ID = /^msg-\d+$/;
/** The refusal the share route answers with while the trip chat is switched off. */
function chatIsOff(err: unknown): boolean {
const res = (err as { response?: { status?: number; data?: { error?: unknown } } } | null)?.response;
return res?.status === 403 && res.data?.error === 'Collab chat is off';
}
/**
* "Share in the trip chat" on one answer, the same on the window and the
* phone: whether it can be shared (a finished answer the server stored, with
* text, in a trip the planner holds, with the chat add-on on and the right to
* post in it), the preview of what goes out, and the post itself. Online
* only, like the chat it posts to; the button stays and says so while
* offline. The answer reads "Shared" afterwards for the rest of the visit.
*/
export function useAssistantShare(tripId: number, message: AssistantMessage, writing: boolean) {
const { t } = useTranslation();
const toast = useToast();
const { offline } = useNetworkMode();
const can = useCanDo();
const trip = useTripStore((s) => (s.trip?.id === tripId ? s.trip : null));
const chatOn = useAddonStore((s) => s.collabChat && s.addons.some((a) => a.id === 'collab' && a.enabled));
const threadId = useAssistantStore((s) => chatOf(s, tripId).threadId);
const [previewing, setPreviewing] = useState(false);
const [includeChanges, setIncludeChanges] = useState(true);
const [sending, setSending] = useState(false);
const [shared, setShared] = useState(false);
const preview = useMemo(() => chatTextOf(message, { includeChanges, t }), [message, includeChanges, t]);
const canShare =
!!trip &&
chatOn &&
can('collab_edit', trip) &&
threadId !== null &&
STORED_ID.test(message.id) &&
message.status === 'complete' &&
!writing &&
preview.text.length > 0;
const send = async () => {
if (!canShare || sending || threadId === null) return;
setSending(true);
try {
await assistantRepo.share(tripId, threadId, message.id, { text: preview.text });
setShared(true);
toast.success(t('assistant.share.done'));
} catch (err) {
if (err instanceof OnlineOnlyError) toast.error(t('assistant.error.offline'));
else if (chatIsOff(err)) toast.error(t('assistant.share.chatOff'));
else toast.error(t('assistant.share.failed'));
} finally {
setSending(false);
}
};
return {
canShare,
/** Offline the button stays, off, and says why. */
offline,
sending,
shared,
previewing,
open: () => setPreviewing(true),
close: () => setPreviewing(false),
preview,
includeChanges,
toggleChanges: () => setIncludeChanges((on) => !on),
send,
};
}
@@ -0,0 +1,48 @@
import { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { useCanDo } from '../../store/permissionsStore';
import { useTripStore } from '../../store/tripStore';
import { localIsoDate } from '../../utils/localDate';
import { suggestionsFor, tripPhase } from './assistantModel';
import { useAssistantFocus } from './useAssistantFocus';
export interface ReadySuggestion {
id: string;
/** What the button says, and the question it sends. */
prompt: string;
}
/**
* The questions the empty chat offers, worded and ready to send, and whether
* the user may change this trip at all. For a trip the planner is not showing
* (one picked from the dashboard) neither its dates nor its rights are at
* hand, so it gets the general questions and none that would change it.
*/
export function useAssistantSuggestions(tripId: number): { suggestions: ReadySuggestion[]; canEdit: boolean } {
const { t } = useTranslation();
const can = useCanDo();
const trip = useTripStore((s) => (s.trip?.id === tripId ? s.trip : null));
const hasPlaces = useTripStore((s) => s.places.length > 0);
const { label, kind } = useAssistantFocus(tripId);
const canEdit = !!trip && can('place_edit', trip);
const suggestions = useMemo(() => {
const phase = trip
? tripPhase({
startDate: trip.start_date ?? null,
endDate: trip.end_date ?? null,
hasPlaces,
today: localIsoDate(),
})
: 'upcoming';
return suggestionsFor({
phase,
canEdit,
place: kind === 'place' ? label : null,
day: kind === 'day' ? label : null,
}).map((s) => ({ id: s.id, prompt: t(s.labelKey, s.params) }));
}, [trip, hasPlaces, canEdit, kind, label, t]);
return { suggestions, canEdit };
}
@@ -0,0 +1,97 @@
/**
* FE-AI-THREAD-001 to FE-AI-THREAD-004
*
* The chat around the messages: showing it resumes the latest conversation,
* hiding it stops an answer still being written, and the header's actions
* reach the store for this trip.
*/
import { act, renderHook } 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 { TranslationProvider } from '../../i18n/TranslationContext';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAssistantThread } from './useAssistantThread';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const resume = vi.fn(async () => {});
const cancel = vi.fn();
const deleteThread = vi.fn(async () => {});
const openThread = vi.fn(async () => {});
const addToast = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
function seed(threadId: number | null, retention: AiStatusResponse['retention']) {
useAssistantStore.setState({
status: { retention } as AiStatusResponse,
resume,
cancel,
deleteThread,
openThread,
byTrip: {
[TRIP]: {
...chatOf(useAssistantStore.getState(), TRIP),
threadId,
threads: [
{ id: 7, title: 'Friday in Kyoto', lastMessageAt: '2026-10-20T10:00:00.000Z' },
{ id: 8, title: 'Packing', lastMessageAt: '2026-10-19T10:00:00.000Z' },
],
},
},
});
}
beforeEach(() => {
window.__addToast = addToast as unknown as typeof window.__addToast;
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
delete window.__addToast;
vi.clearAllMocks();
});
describe('useAssistantThread', () => {
it('FE-AI-THREAD-001: resumes the trip on show and stops a running answer on hide', () => {
seed(7, { mode: 'days', days: 90 });
const { unmount } = renderHook(() => useAssistantThread(TRIP), { wrapper });
expect(resume).toHaveBeenCalledWith(TRIP);
expect(cancel).not.toHaveBeenCalled();
unmount();
expect(cancel).toHaveBeenCalledWith(TRIP);
});
it('FE-AI-THREAD-002: names the open conversation and how long chats are kept', () => {
seed(7, { mode: 'days', days: 90 });
const { result, rerender } = renderHook(() => useAssistantThread(TRIP), { wrapper });
expect(result.current.title).toBe('Friday in Kyoto');
expect(result.current.retentionNote).toBe('Chats are deleted after 90 days.');
act(() => seed(null, { mode: 'forever' }));
rerender();
expect(result.current.title).toBeNull();
expect(result.current.retentionNote).toBe('Chats are kept until you delete them.');
});
it('FE-AI-THREAD-003: opens and deletes conversations of this trip only', async () => {
seed(7, { mode: 'days', days: 30 });
const { result } = renderHook(() => useAssistantThread(TRIP), { wrapper });
act(() => result.current.openThread(8));
expect(openThread).toHaveBeenCalledWith(TRIP, 8);
await act(() => result.current.removeThread());
expect(deleteThread).toHaveBeenCalledWith(TRIP, 7);
});
it('FE-AI-THREAD-004: a failed delete says so and keeps the conversation', async () => {
seed(7, { mode: 'days', days: 30 });
deleteThread.mockRejectedValueOnce(new Error('offline'));
const { result } = renderHook(() => useAssistantThread(TRIP), { wrapper });
await act(() => result.current.removeThread());
expect(addToast).toHaveBeenCalledWith('Something went wrong.', 'error', undefined);
expect(chatOf(useAssistantStore.getState(), TRIP).threadId).toBe(7);
});
});
@@ -0,0 +1,69 @@
import { useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useToast } from '../shared/Toast';
/**
* The chat around the messages, shared by the desktop window and the phone
* screen: which conversation is open, the latest ones to switch to, starting
* a new one or deleting this one, and how long chats are kept. Each view
* draws its own header from it.
*
* Showing the chat resumes the trip's latest conversation; hiding it (the
* window closed, the screen left) stops an answer still being written, so no
* stream runs on with nobody watching.
*/
export function useAssistantThread(tripId: number) {
const { t } = useTranslation();
const toast = useToast();
const { threadId, threads, isRunning } = useAssistantStore(
useShallow((s) => {
const chat = chatOf(s, tripId);
return { threadId: chat.threadId, threads: chat.threads, isRunning: chat.isRunning };
})
);
const retention = useAssistantStore((s) => s.status?.retention ?? null);
const resume = useAssistantStore((s) => s.resume);
const loadThreads = useAssistantStore((s) => s.loadThreads);
const openThread = useAssistantStore((s) => s.openThread);
const newThread = useAssistantStore((s) => s.newThread);
const deleteThread = useAssistantStore((s) => s.deleteThread);
const cancel = useAssistantStore((s) => s.cancel);
useEffect(() => {
void resume(tripId);
return () => cancel(tripId);
}, [tripId, resume, cancel]);
const title = (threadId !== null && threads?.find((th) => th.id === threadId)?.title) || null;
let retentionNote: string | null = null;
if (retention?.mode === 'forever') retentionNote = t('assistant.history.forever');
else if (retention?.mode === 'days') retentionNote = t('assistant.history.retention', { count: retention.days });
const removeThread = async () => {
if (threadId === null) return;
try {
await deleteThread(tripId, threadId);
} catch {
toast.error(t('assistant.error.generic'));
}
};
return {
threadId,
/** The latest conversations of this trip; null until loaded. */
threads,
/** The open conversation's title; null for a new one. */
title,
isRunning,
retentionNote,
refreshThreads: () => void loadThreads(tripId),
openThread: (id: number) => void openThread(tripId, id),
newThread: () => newThread(tripId),
removeThread,
stop: () => cancel(tripId),
};
}
@@ -0,0 +1,74 @@
/**
* FE-AI-PICK-001 to FE-AI-PICK-004
*
* Which trips the assistant offers outside a trip, and in what order: the
* running one, then the ones to come, then the ones behind.
*/
import { renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { tripRepo } from '../../repo/tripRepo';
import type { Trip } from '../../types';
import { rankPickerTrips, useAssistantTripPicker } from './useAssistantTripPicker';
const TODAY = '2026-10-21';
const trip = (id: number, start: string | null, end: string | null) => ({
id,
title: `Trip ${id}`,
start_date: start,
end_date: end,
});
afterEach(() => vi.restoreAllMocks());
describe('rankPickerTrips', () => {
it('FE-AI-PICK-001: the running trip leads, then the soonest to come, then the latest past one', () => {
const ranked = rankPickerTrips(
[
trip(1, '2026-01-02', '2026-01-09'),
trip(2, '2026-12-01', '2026-12-05'),
trip(3, '2026-10-20', '2026-10-25'),
trip(4, '2026-11-01', '2026-11-04'),
trip(5, '2026-05-01', '2026-05-03'),
],
TODAY
);
expect(ranked.map((t) => [t.id, t.badge])).toEqual([
[3, 'now'],
[4, 'next'],
[2, null],
[5, null],
[1, null],
]);
});
it('FE-AI-PICK-002: a trip without dates counts as to come, after the dated ones, and is never "next"', () => {
const ranked = rankPickerTrips([trip(1, null, null), trip(2, '2026-11-01', null)], TODAY);
expect(ranked.map((t) => [t.id, t.badge])).toEqual([
[2, 'next'],
[1, null],
]);
});
it('FE-AI-PICK-003: offers at most eight trips', () => {
const many = Array.from({ length: 12 }, (_, i) => trip(i + 1, `2027-01-${String(i + 1).padStart(2, '0')}`, null));
expect(rankPickerTrips(many, TODAY)).toHaveLength(8);
});
});
describe('useAssistantTripPicker', () => {
it('FE-AI-PICK-004: loads the trips once and ranks them; a failed load offers none', async () => {
const list = vi
.spyOn(tripRepo, 'list')
.mockResolvedValueOnce({ trips: [trip(7, '2026-10-01', '2026-10-30') as Trip], archivedTrips: [] });
const { result } = renderHook(() => useAssistantTripPicker());
expect(result.current).toBeNull();
await waitFor(() => expect(result.current).toEqual([{ id: 7, title: 'Trip 7', badge: 'now' }]));
expect(list).toHaveBeenCalledTimes(1);
list.mockRejectedValueOnce(new Error('down'));
const failed = renderHook(() => useAssistantTripPicker());
await waitFor(() => expect(failed.result.current).toEqual([]));
});
});
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { tripRepo } from '../../repo/tripRepo';
import { localIsoDate } from '../../utils/localDate';
export interface PickerTrip {
id: number;
title: string;
/** The trip runs today, or is the next to start. */
badge: 'now' | 'next' | null;
}
type DatedTrip = { id: number; title: string; start_date?: string | null; end_date?: string | null };
const MAX_TRIPS = 8;
/**
* The trips worth asking about first: the one running today, then the ones
* still to come (soonest first), then the ones behind (latest first). The
* running trip is marked "now" and the first one to come "next".
*/
export function rankPickerTrips(trips: readonly DatedTrip[], today: string): PickerTrip[] {
const phase = (t: DatedTrip) => {
if (t.start_date && t.start_date <= today && (!t.end_date || today <= t.end_date)) return 0;
if (!t.start_date || t.start_date > today) return 1;
return 2;
};
const ranked = [...trips].sort((a, b) => {
const pa = phase(a);
const pb = phase(b);
if (pa !== pb) return pa - pb;
const sa = a.start_date ?? '';
const sb = b.start_date ?? '';
// Undated trips to come go last among them; the past runs from the latest back.
if (pa === 1) return (sa === '' ? 1 : 0) - (sb === '' ? 1 : 0) || sa.localeCompare(sb);
return sb.localeCompare(sa);
});
let nextGiven = false;
return ranked.slice(0, MAX_TRIPS).map((t) => {
const p = phase(t);
let badge: PickerTrip['badge'] = null;
if (p === 0) badge = 'now';
else if (p === 1 && t.start_date && !nextGiven) {
badge = 'next';
nextGiven = true;
}
return { id: t.id, title: t.title, badge };
});
}
/** The trips the picker offers, loaded when it is shown; null while loading. */
export function useAssistantTripPicker(): PickerTrip[] | null {
const [trips, setTrips] = useState<PickerTrip[] | null>(null);
useEffect(() => {
let cancelled = false;
tripRepo
.list()
.then(({ trips: list }) => {
if (!cancelled) setTrips(rankPickerTrips(list, localIsoDate()));
})
.catch(() => {
// Nothing to pick from is what the picker shows; the window stays usable to close.
if (!cancelled) setTrips([]);
});
return () => {
cancelled = true;
};
}, []);
return trips;
}
@@ -0,0 +1,48 @@
import { type PointerEvent, useCallback, useEffect } from 'react';
import { useAssistantStore } from '../../store/assistantStore';
/**
* Ties one change in the card to its pin on the map: resting on the row (or
* tapping it on a touch screen) lights the pin, and resting on the pin lights
* the row. Each row reads only whether it is the one lit, so a hover renders
* the row it enters and the one it leaves, not the whole card.
*/
export function useChangeHover(tripId: number, toolCallId: string | null) {
const hovered = useAssistantStore((s) =>
toolCallId !== null && s.mapLink?.tripId === tripId && s.mapLink.hoveredId === toolCallId
? s.mapLink.source
: null
);
const setMapHover = useAssistantStore((s) => s.setMapHover);
const enter = useCallback(() => {
if (toolCallId !== null) setMapHover(tripId, toolCallId, 'card');
}, [setMapHover, tripId, toolCallId]);
const leave = useCallback(() => {
const link = useAssistantStore.getState().mapLink;
// Only the row that lit the pin puts it out; the pointer may already rest on another.
if (toolCallId !== null && link?.tripId === tripId && link.hoveredId === toolCallId) {
setMapHover(tripId, null, 'card');
}
}, [setMapHover, tripId, toolCallId]);
// A row that goes (the change was decided, the chat closed) takes its light with it.
useEffect(() => leave, [leave]);
const onPointerDown = useCallback(
(e: PointerEvent) => {
if (e.pointerType === 'touch') enter();
},
[enter]
);
return {
/** Lit from anywhere: the card, the map, a found place. */
lit: hovered !== null,
/** Lit from its pin on the map, where the card shows it itself. */
litFromMap: hovered === 'map',
handlers: { onMouseEnter: enter, onMouseLeave: leave, onFocus: enter, onBlur: leave, onPointerDown },
};
}
@@ -0,0 +1,60 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { type RefObject, useEffect, useState } from 'react';
import { fetchPhoto, getCached } from '../../services/photoService';
import { useAuthStore } from '../../store/authStore';
/** The only pictures a found place shows: TREK's own photo proxy, never an address from the search. */
const PROXY_PREFIX = '/api/maps/place-photo/';
/** A picture TREK's proxy serves, or null for anything else. */
function proxiedPhoto(url: string | null | undefined): string | null {
return url?.startsWith(PROXY_PREFIX) ? url : null;
}
/**
* The picture of a found place, asked from the photo proxy only once its card
* scrolls into view: each one may cost the instance a call to the photo
* provider, and a strip holds several. The answer goes through the shared
* photo cache and its limit on parallel requests, so a place the planner shows
* too is fetched once. Null while it loads, when there is none, when photos
* are off, and for any answer that is not the proxy's own address.
*/
export function useFoundPlacePhoto(place: AssistantFoundPlace, ref: RefObject<Element | null>): string | null {
const enabled = useAuthStore((s) => s.placesPhotosEnabled);
const key = place.photoKey;
const [src, setSrc] = useState<string | null>(() => (key ? proxiedPhoto(getCached(key)?.photoUrl) : null));
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!enabled || !key || !el) return;
if (typeof IntersectionObserver === 'undefined') {
setVisible(true);
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
setVisible(true);
io.disconnect();
},
{ rootMargin: '120px' }
);
io.observe(el);
return () => io.disconnect();
}, [enabled, key, ref]);
useEffect(() => {
if (!visible || !enabled || !key) return;
let cancelled = false;
fetchPhoto(key, key, place.lat, place.lng, place.name, (entry) => {
if (!cancelled) setSrc(proxiedPhoto(entry.photoUrl));
});
return () => {
cancelled = true;
};
}, [visible, enabled, key, place.lat, place.lng, place.name]);
return enabled ? src : null;
}
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { prefersReducedMotion } from '../../utils/reducedMotion';
/** Milliseconds per typed and per erased character, and the pauses around a full phrase. */
const TYPE_MS = 45;
const ERASE_MS = 22;
const HOLD_MS = 2200;
const GAP_MS = 400;
/**
* A placeholder that writes questions the user could ask, one after another:
* it types a phrase, holds it, erases it and moves on to the next. It runs
* only while `active` (the field is empty) and stands still on `fallback`
* under reduced motion or without phrases, so it never moves under someone's
* typing or against their settings.
*/
export function useTypewriterPlaceholder(phrases: readonly string[], fallback: string, active: boolean): string {
const [text, setText] = useState('');
const key = phrases.join('\n');
const still = !active || phrases.length === 0 || prefersReducedMotion();
useEffect(() => {
if (still) return;
const list = key.split('\n');
let phrase = 0;
let length = 0;
let erasing = false;
let timer = 0;
const step = () => {
const current = list[phrase] ?? '';
if (!erasing) {
length += 1;
setText(current.slice(0, length));
if (length >= current.length) {
erasing = true;
timer = window.setTimeout(step, HOLD_MS);
return;
}
timer = window.setTimeout(step, TYPE_MS);
return;
}
length -= 1;
setText(current.slice(0, Math.max(0, length)));
if (length <= 0) {
erasing = false;
phrase = (phrase + 1) % list.length;
timer = window.setTimeout(step, GAP_MS);
return;
}
timer = window.setTimeout(step, ERASE_MS);
};
timer = window.setTimeout(step, GAP_MS);
return () => window.clearTimeout(timer);
}, [key, still]);
return still ? fallback : text || '​';
}
@@ -0,0 +1,25 @@
// FE-COLLAB-AIBADGE-001 to FE-COLLAB-AIBADGE-003
import { render, screen } from '../../../tests/helpers/render';
import CollabAssistantBadge from './CollabAssistantBadge';
import { isFromAssistant } from './collabModel';
describe('CollabAssistantBadge', () => {
it('FE-COLLAB-AIBADGE-001: labels the message as coming from the assistant', () => {
render(<CollabAssistantBadge name="Alice" />);
expect(screen.getByText('from the assistant')).toBeInTheDocument();
});
it('FE-COLLAB-AIBADGE-002: renders the same label in the phone tones', () => {
render(<CollabAssistantBadge name="Alice" phone />);
expect(screen.getByText('from the assistant').className).toContain('text-m-muted');
});
});
describe('isFromAssistant', () => {
it('FE-COLLAB-AIBADGE-003: is true only for the assistant origin', () => {
expect(isFromAssistant({ origin: 'assistant' })).toBe(true);
expect(isFromAssistant({ origin: null })).toBe(false);
expect(isFromAssistant({})).toBe(false);
expect(isFromAssistant({ origin: 'something-else' })).toBe(false);
});
});
@@ -0,0 +1,28 @@
import { Sparkles } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { Tooltip } from '../shared/Tooltip';
interface CollabAssistantBadgeProps {
/** Who shared the answer, named in the tooltip. */
name: string;
phone?: boolean;
}
/**
* The small label on a chat message someone shared from an assistant answer.
* The server sets the origin, so the label reads in each reader's own language
* and nobody can type it into a plain message.
*/
export default function CollabAssistantBadge({ name, phone = false }: CollabAssistantBadgeProps) {
const { t } = useTranslation();
const tone = phone ? 'bg-m-inner text-m-muted' : 'bg-surface-secondary text-content-muted';
return (
<Tooltip label={t('collab.chat.fromAssistantHint', { name })} placement="top">
<span className={`inline-flex items-center gap-1 rounded-full px-1.5 text-caption font-medium ${tone}`}>
<Sparkles size={10} aria-hidden="true" />
{t('collab.chat.fromAssistant')}
</span>
</Tooltip>
);
}
@@ -1,4 +1,4 @@
// FE-W5CCM-001 to FE-W5CCM-024
// FE-W5CCM-001 to FE-W5CCM-025
// ChatMessages is a pure presentational component — every piece of state arrives
// as a prop from useCollabChat, so the tests drive it directly instead of going
// through CollabChat (that path is covered in CollabChat.test.tsx).
@@ -26,6 +26,7 @@ interface ChatMsg {
reply_to?: number | null
reactions?: { emoji: string; count: number; users: { user_id: number; username: string }[] }[]
_deleted?: boolean
origin?: string | null
}
const currentUser = { id: 1, username: 'me' }
@@ -297,4 +298,11 @@ describe('ChatMessages', () => {
const big = screen.getByText('🎉')
expect(big.getAttribute('style')).toContain('font-size: calc(40px')
})
it('FE-W5CCM-025: a shared assistant answer carries its label, a typed message none', () => {
renderMessages([buildMsg({ id: 1, text: 'typed' }), buildMsg({ id: 2, text: 'shared', origin: 'assistant' })])
expect(screen.getAllByText('from the assistant')).toHaveLength(1)
expect(screen.getByText('shared').closest('div[style*="column"]')).toHaveTextContent('from the assistant')
expect(screen.getByText('typed').closest('div[style*="column"]')).not.toHaveTextContent('from the assistant')
})
})

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