mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a8ce4013b | |||
| 4abab7a777 | |||
| f8dc7ad011 | |||
| 680c52dc85 | |||
| e3d40ddcf6 | |||
| 00b50e280f | |||
| f8e29dd218 | |||
| 2fbff51299 | |||
| 55c6bad295 | |||
| 858c77422c | |||
| e5f1f573c5 | |||
| b721234249 | |||
| a1f97ba548 | |||
| 77ad0a06d0 | |||
| ea7120e141 | |||
| 7e563e1170 | |||
| 3c3a228737 | |||
| b6f7488591 | |||
| 26df56d76c | |||
| 214984f398 | |||
| bb3c0151d5 | |||
| 3101b36f3d | |||
| ef4eac34c4 | |||
| a518b8dfc2 | |||
| 8523bbb724 | |||
| 464d4e006c | |||
| 1797e1c70d | |||
| 5dc945145f | |||
| a1180b2b19 | |||
| d62fc284e1 | |||
| 8f53d98d45 | |||
| 9b71948497 | |||
| bb10b94046 | |||
| 94c369b913 | |||
| 3f1d1a57cc | |||
| 7c1de6ed82 | |||
| fbf6cd2840 | |||
| 5116f7be2c | |||
| 4f60cc5c98 | |||
| 65bb2d95eb | |||
| cbca3b2851 | |||
| 8a75712041 | |||
| 6e13f7e655 | |||
| 41f3267503 | |||
| 2a4a5617dc | |||
| 43353b9394 | |||
| 4eaf5e6a11 | |||
| 1a722e63fb | |||
| 924e38472e | |||
| bf2efca8f0 | |||
| 2377a63a05 | |||
| 377619abd1 | |||
| 988890866a | |||
| 0dda5bc3e9 | |||
| 7fa8b3fe35 | |||
| 112017a9e8 | |||
| cb5c5ab4a1 | |||
| bed9f53615 | |||
| a8e2486cd3 | |||
| 5d5015066f | |||
| 3e8d192305 | |||
| bafb74f872 | |||
| b71e32cf12 | |||
| 786df612fd | |||
| e6002444a1 | |||
| e453816ded | |||
| b9ca822d51 | |||
| 20537193b6 | |||
| 60611aee65 | |||
| 6c66cff365 | |||
| d3f574f225 | |||
| e5c5ffbc00 | |||
| 562176bd83 | |||
| ae699187f7 | |||
| 1d72fbd25f | |||
| 2d0c0597b3 | |||
| a85a50e186 | |||
| c8eb510bd4 | |||
| beea4c762b | |||
| d924dcf99d | |||
| 371083ed47 | |||
| eaeaf68352 | |||
| 5156fa2c0a | |||
| 6c6e285c3d | |||
| 93e1156aa4 | |||
| dc19807126 | |||
| 5580ff71a9 | |||
| e42622d71d | |||
| 94674e7572 | |||
| 7f14dde362 | |||
| 9765cf3fee | |||
| c38eb7ebf0 | |||
| e653733390 | |||
| 2655e510fc | |||
| 904eb31e62 | |||
| 76ec302526 | |||
| 810feba413 |
@@ -26,5 +26,8 @@
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
# Ski area graphs (SGR1) as the Places host writes them, byte for byte
|
||||
*.sgr binary
|
||||
*.gz binary
|
||||
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
|
||||
server/tests/fixtures/gpx/bom-crlf.gpx -text
|
||||
|
||||
@@ -102,8 +102,8 @@ env:
|
||||
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
|
||||
# Defaults to 25000.
|
||||
# TREK_PLACES_ENABLED: "false"
|
||||
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
|
||||
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
|
||||
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads,
|
||||
# and the OpenStreetMap ways and horse routes Tours reads) asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
|
||||
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
|
||||
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
|
||||
# here. Defaults to on.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 874,
|
||||
"@typescript-eslint/no-misused-promises": 870,
|
||||
"no-console": 88,
|
||||
"complexity": 230,
|
||||
"@typescript-eslint/no-explicit-any": 147,
|
||||
"trek/no-swallowed-catch": 133,
|
||||
"max-params": 35,
|
||||
"trek/store-without-selector": 69,
|
||||
"react-hooks/refs": 558,
|
||||
"react-hooks/set-state-in-effect": 197,
|
||||
"react-hooks/refs": 557,
|
||||
"react-hooks/set-state-in-effect": 196,
|
||||
"@typescript-eslint/no-floating-promises": 167,
|
||||
"react-hooks/exhaustive-deps": 121,
|
||||
"@typescript-eslint/no-unused-vars": 129,
|
||||
"@typescript-eslint/no-unused-vars": 127,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
"react-hooks/static-components": 78,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 491,
|
||||
"@typescript-eslint/no-non-null-assertion": 1946,
|
||||
"@typescript-eslint/no-non-null-assertion": 1945,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -16,12 +16,17 @@
|
||||
"feature": "Addon manager",
|
||||
"hooks": [
|
||||
"src/components/Admin/addons/addonModel.ts",
|
||||
"src/components/Admin/addons/useAddonManager.ts",
|
||||
"src/components/Admin/addons/useLlmParsingConfig.ts"
|
||||
"src/components/Admin/addons/useAddonManager.ts"
|
||||
],
|
||||
"desktop": ["src/components/Admin/AddonManager.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Addon manager: AI tile",
|
||||
"hooks": ["src/components/Admin/addons/useAiAddonTile.ts"],
|
||||
"desktop": ["src/components/Admin/addons/AiAddonTile.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Admin notification preference matrix",
|
||||
"hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"],
|
||||
@@ -58,6 +63,21 @@
|
||||
"desktop": ["src/pages/admin/AdminUsersTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminUsersSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "AI assistant chat",
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Assistant hints",
|
||||
"hooks": ["src/components/Assistant/useAssistantHints.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantHeader.tsx", "src/components/Assistant/AssistantLauncher.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/assistant/MAssistantScreen.tsx",
|
||||
"src/mobile/screens/trip/sheets/MMehrSheet.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Atlas country/region action sheet",
|
||||
"hooks": ["src/pages/atlas/useAtlasCountryActions.ts"],
|
||||
@@ -223,6 +243,15 @@
|
||||
"desktop": ["src/pages/CollectionsPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/collections/MCollections.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Collection tours (LIB-03): useCollectionTours, useCollectionTourTakeOver",
|
||||
"hooks": [
|
||||
"src/components/Tours/collection/useCollectionTours.ts",
|
||||
"src/components/Tours/collection/useCollectionTourTakeOver.ts"
|
||||
],
|
||||
"desktop": ["src/components/Tours/collection/CollectionToursSection.tsx"],
|
||||
"mobile": ["src/mobile/screens/collections/MCollTours.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Copy collection places to a trip",
|
||||
"hooks": ["src/components/Collections/useTripCopyPicker.ts"],
|
||||
@@ -390,6 +419,18 @@
|
||||
"src/mobile/screens/journey/mobileJourneyMeta.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Journey day take-over: useJourneyDayImport",
|
||||
"hooks": ["src/components/Journey/useJourneyDayImport.ts"],
|
||||
"desktop": ["src/components/Journey/JourneyDayImportDialog.tsx"],
|
||||
"mobile": ["src/mobile/screens/journey/MJourneyDayImportSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Journey day recording: useJourneyRecordingDay",
|
||||
"hooks": ["src/components/Journey/useJourneyRecordingDay.ts"],
|
||||
"desktop": ["src/components/Journey/JourneyRecordingDialog.tsx"],
|
||||
"mobile": ["src/mobile/screens/journey/MJourneyRecordingSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Journey detail page: useJourneyDetail",
|
||||
"hooks": ["src/pages/journeyDetail/useJourneyDetail.ts"],
|
||||
@@ -577,6 +618,18 @@
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "A tour's start and end in the day plan, its end worked out from its time, and an end that runs into the next timed item",
|
||||
"hooks": ["src/components/Tours/tourDayTime.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "A multi-day tour put on a day: the whole tour there, or a stage a day from that day on",
|
||||
"hooks": ["src/components/Tours/stages/stageSpread.ts"],
|
||||
"desktop": ["src/components/Tours/stages/TourDayChoiceDialog.tsx", "src/components/Tours/stages/TourDayAddButton.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MTourDayChoiceSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Reservation form incl. hotel accommodation creation",
|
||||
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
||||
@@ -619,6 +672,12 @@
|
||||
"desktop": ["src/components/Settings/DisplaySettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsGeneral.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Integrations / AI assistant",
|
||||
"hooks": ["src/components/Settings/ai/useAiConnections.ts"],
|
||||
"desktop": ["src/components/Settings/ai/AiConnectionSection.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MAiConnectionSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Integrations / AI parsing",
|
||||
"hooks": ["src/components/Settings/useLlmConnection.ts"],
|
||||
@@ -880,6 +939,7 @@
|
||||
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
|
||||
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
|
||||
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
|
||||
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
|
||||
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
||||
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
||||
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
|
||||
@@ -918,7 +978,6 @@
|
||||
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
|
||||
"src/mobile/screens/trip/sheets/MImportSheet.tsx": "Menu that routes to ImpFileStep and ImpListStep, with no logic of its own.",
|
||||
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
|
||||
"src/mobile/screens/trip/sheets/MMehrSheet.tsx": "Phone dock overflow menu that only opens tabs and sheets.",
|
||||
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
|
||||
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
|
||||
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"planner.ts": 53,
|
||||
"reservations.ts": 21,
|
||||
"roadtrip.ts": 38,
|
||||
"settings.ts": 40,
|
||||
"settings.ts": 37,
|
||||
"share.ts": 7,
|
||||
"shared.ts": 2,
|
||||
"storage.ts": 16,
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
"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,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2969,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2963,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1193,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
"src/components/Studio/TravelElements.tsx": 1738,
|
||||
"src/components/Studio/autoLayout.ts": 1691,
|
||||
@@ -24,7 +24,7 @@
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1141,
|
||||
"src/pages/TripPlannerPage.tsx": 1140,
|
||||
"src/pages/atlas/useAtlas.ts": 1287,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||
|
||||
@@ -148,7 +148,6 @@
|
||||
"tests/integration/api/client.test.ts": 1,
|
||||
"tests/integration/hooks/useRouteCalculation.test.ts": 1,
|
||||
"tests/unit/i18n/i18nKeysScript.test.ts": 3,
|
||||
"tests/unit/mobile/admin/MAdminAddonManager.test.tsx": 2,
|
||||
"tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx": 1,
|
||||
"tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx": 1,
|
||||
"tests/unit/mobile/admin/MAdminStoragePanel.test.tsx": 1,
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
"mobile/components/MToastHost.tsx": 2,
|
||||
"mobile/components/MUserPickerList.tsx": 1,
|
||||
"mobile/screens/admin/MAdmin.tsx": 9,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 26,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 13,
|
||||
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
|
||||
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
|
||||
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
ASSISTANT_ERROR_CODES,
|
||||
type AiAdminConnectionResponse,
|
||||
type AiAdminSettings,
|
||||
type AiApiKeyConnectRequest,
|
||||
type AiAuthStartResponse,
|
||||
type AiChatgptFinishRequest,
|
||||
type AiClaudeConnectorResponse,
|
||||
type AiConnectionScope,
|
||||
type AiConnectionView,
|
||||
type AiInstanceConnectionRequest,
|
||||
type AiModelListResponse,
|
||||
type AiOpenrouterFinishRequest,
|
||||
type AiPrivacyAckRequest,
|
||||
type AiStatusResponse,
|
||||
type AssistantAddFoundPlaceRequest,
|
||||
type AssistantAddFoundPlaceResponse,
|
||||
type AssistantChatRequest,
|
||||
type AssistantDecisionsRequest,
|
||||
type AssistantErrorCode,
|
||||
type AssistantShareRequest,
|
||||
type AssistantThreadListResponse,
|
||||
type AssistantThreadResponse,
|
||||
type AssistantUndoResponse,
|
||||
type CollabMessageResponse,
|
||||
type SuccessResponse,
|
||||
type UiStreamChunk,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
import { parseUiMessageStream } from './uiMessageStream';
|
||||
import { getSocketId } from './websocket';
|
||||
|
||||
/**
|
||||
* The trip assistant and the AI connections behind it, in their own file
|
||||
* rather than in the `client.ts` god module.
|
||||
*
|
||||
* The two streaming calls (a new message, and the decisions on held changes)
|
||||
* go through `fetch`, not axios: the answer is a server-sent event stream that
|
||||
* runs for as long as the model works, and axios would buffer it whole behind
|
||||
* its 8 s timeout. Everything else is plain JSON through `apiClient`.
|
||||
*/
|
||||
|
||||
/** A refusal the server answered before the stream opened, named by its `AssistantErrorCode`. */
|
||||
export class AssistantRequestError extends Error {
|
||||
readonly code: AssistantErrorCode;
|
||||
readonly status: number;
|
||||
|
||||
constructor(code: AssistantErrorCode, status: number) {
|
||||
super(`Assistant request refused: ${code}`);
|
||||
this.name = 'AssistantRequestError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_CODES: ReadonlySet<string> = new Set(ASSISTANT_ERROR_CODES);
|
||||
|
||||
/**
|
||||
* The code of a refusal: the body's `code` when it names one, otherwise what
|
||||
* the status alone can say.
|
||||
*/
|
||||
async function refusalCode(res: Response): Promise<AssistantErrorCode> {
|
||||
try {
|
||||
const body: unknown = await res.json();
|
||||
const code = (body as { code?: unknown } | null)?.code;
|
||||
if (typeof code === 'string' && KNOWN_CODES.has(code)) return code as AssistantErrorCode;
|
||||
} catch {
|
||||
// Not JSON (a proxy's error page); the status below still says something.
|
||||
}
|
||||
// A bare 429 is TREK's own limit or a proxy's; a provider's limit always names its code.
|
||||
if (res.status === 429) return 'too_many_requests';
|
||||
if (res.status === 409) return 'busy';
|
||||
if (res.status === 502 || res.status === 503 || res.status === 504) return 'provider_unavailable';
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
async function openStream(path: string, body: unknown, signal: AbortSignal): Promise<AsyncGenerator<UiStreamChunk>> {
|
||||
// A file goes as multipart, where the browser sets the content type with its boundary.
|
||||
const form = body instanceof FormData;
|
||||
const headers: Record<string, string> = form
|
||||
? { accept: 'text/event-stream' }
|
||||
: { 'content-type': 'application/json', accept: 'text/event-stream' };
|
||||
const socketId = getSocketId();
|
||||
if (socketId) headers['X-Socket-Id'] = socketId;
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: form ? body : JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new AssistantRequestError(await refusalCode(res), res.status);
|
||||
if (!res.body) throw new AssistantRequestError('generic', res.status);
|
||||
return parseUiMessageStream(res.body);
|
||||
}
|
||||
|
||||
const tripBase = (tripId: number) => `/trips/${tripId}/assistant`;
|
||||
|
||||
/**
|
||||
* For the calls that make the server go and ask the provider (listing models,
|
||||
* testing a key, exchanging a sign-in code): the shared 8 s is TREK's own
|
||||
* budget and too short for a provider's.
|
||||
*/
|
||||
const viaProvider = { timeout: 30_000 };
|
||||
|
||||
export const assistantApi = {
|
||||
threads: (tripId: number): Promise<AssistantThreadListResponse> =>
|
||||
apiClient.get(`${tripBase(tripId)}/threads`).then((r) => r.data),
|
||||
thread: (tripId: number, threadId: number): Promise<AssistantThreadResponse> =>
|
||||
apiClient.get(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
|
||||
deleteThread: (tripId: number, threadId: number): Promise<SuccessResponse> =>
|
||||
apiClient.delete(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
|
||||
undo: (tripId: number, toolCallId: string): Promise<AssistantUndoResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/tool-calls/${encodeURIComponent(toolCallId)}/undo`, {}).then((r) => r.data),
|
||||
// Ends this user's running turn, wherever it runs (another tab included).
|
||||
stop: (tripId: number): Promise<SuccessResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/stop`, {}).then((r) => r.data),
|
||||
// A place a search found, suggested for a day: answers with the message holding the change.
|
||||
addFoundPlace: (
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
body: AssistantAddFoundPlaceRequest
|
||||
): Promise<AssistantAddFoundPlaceResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
|
||||
// An answer posted to the trip chat, marked as shared from the assistant; the client's socket id skips the echo.
|
||||
share: (
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
messageId: string,
|
||||
body: AssistantShareRequest
|
||||
): Promise<CollabMessageResponse> =>
|
||||
apiClient
|
||||
.post(`${tripBase(tripId)}/threads/${threadId}/messages/${encodeURIComponent(messageId)}/share`, body)
|
||||
.then((r) => r.data),
|
||||
|
||||
chat: (tripId: number, body: AssistantChatRequest, signal: AbortSignal) =>
|
||||
openStream(`${tripBase(tripId)}/chat`, body, signal),
|
||||
decide: (tripId: number, threadId: number, body: AssistantDecisionsRequest, signal: AbortSignal) =>
|
||||
openStream(`${tripBase(tripId)}/threads/${threadId}/decisions`, body, signal),
|
||||
// A booking file: the server reads it and proposes each booking it finds as a change.
|
||||
attach: (tripId: number, form: FormData, signal: AbortSignal) =>
|
||||
openStream(`${tripBase(tripId)}/attachments`, form, signal),
|
||||
};
|
||||
|
||||
/** A user's own connection, and what the assistant runs on for them. */
|
||||
export const aiApi = {
|
||||
status: (): Promise<AiStatusResponse> => apiClient.get('/ai/status').then((r) => r.data),
|
||||
models: (): Promise<AiModelListResponse> => apiClient.get('/ai/models', viaProvider).then((r) => r.data),
|
||||
// Saving tests the key against the provider first.
|
||||
connectKey: (body: AiApiKeyConnectRequest): Promise<AiConnectionView> =>
|
||||
apiClient.put('/ai/connection', body, viaProvider).then((r) => r.data),
|
||||
setModel: (model: string): Promise<AiConnectionView> =>
|
||||
apiClient.patch('/ai/connection/model', { model }).then((r) => r.data),
|
||||
disconnect: (): Promise<SuccessResponse> => apiClient.delete('/ai/connection').then((r) => r.data),
|
||||
ackPrivacy: (body: AiPrivacyAckRequest): Promise<SuccessResponse> =>
|
||||
apiClient.post('/ai/privacy-ack', body).then((r) => r.data),
|
||||
|
||||
chatgptStart: (scope: AiConnectionScope = 'user'): Promise<AiAuthStartResponse> =>
|
||||
apiClient
|
||||
.post(scope === 'instance' ? '/admin/ai/connect/chatgpt/start' : '/ai/connect/chatgpt/start', {})
|
||||
.then((r) => r.data),
|
||||
chatgptFinish: (body: AiChatgptFinishRequest): Promise<AiConnectionView> =>
|
||||
apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data),
|
||||
chatgptSignOut: (): Promise<SuccessResponse> =>
|
||||
apiClient.post('/ai/connect/chatgpt/sign-out', {}).then((r) => r.data),
|
||||
|
||||
openrouterStart: (scope: AiConnectionScope): Promise<AiAuthStartResponse> =>
|
||||
apiClient
|
||||
.post(scope === 'instance' ? '/admin/ai/connect/openrouter/start' : '/ai/connect/openrouter/start', { scope })
|
||||
.then((r) => r.data),
|
||||
openrouterFinish: (body: AiOpenrouterFinishRequest): Promise<AiConnectionView> =>
|
||||
apiClient.post('/ai/connect/openrouter/finish', body, viaProvider).then((r) => r.data),
|
||||
};
|
||||
|
||||
/** The connection the instance provides for everyone, and the assistant's admin settings. */
|
||||
export const aiAdminApi = {
|
||||
settings: (): Promise<AiAdminSettings> => apiClient.get('/admin/ai/settings').then((r) => r.data),
|
||||
saveSettings: (body: AiAdminSettings): Promise<AiAdminSettings> =>
|
||||
apiClient.put('/admin/ai/settings', body).then((r) => r.data),
|
||||
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
|
||||
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
|
||||
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
|
||||
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
||||
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const claudeConnectorApi = {
|
||||
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// FE-API-JOURNEY-MAP-001
|
||||
//
|
||||
// JRN-04: both reads pin their path and hand back what the server answered.
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { journeyMapApi } from './journeyMap';
|
||||
|
||||
describe('journeyMapApi', () => {
|
||||
it('FE-API-JOURNEY-MAP-001: reads the layers of a journey and those of a share link', async () => {
|
||||
const layers = { places: { status: 'withheld' } };
|
||||
const paths: string[] = [];
|
||||
server.use(
|
||||
http.get('/api/journeys/7/map-layers', ({ request }) => {
|
||||
paths.push(new URL(request.url).pathname);
|
||||
return HttpResponse.json(layers);
|
||||
}),
|
||||
http.get('/api/public/journey/:token/map-layers', ({ request }) => {
|
||||
paths.push(new URL(request.url).pathname);
|
||||
return HttpResponse.json(layers);
|
||||
})
|
||||
);
|
||||
expect(await journeyMapApi.layers(7)).toEqual(layers);
|
||||
expect(await journeyMapApi.publicLayers('a/b')).toEqual(layers);
|
||||
expect(paths).toEqual(['/api/journeys/7/map-layers', '/api/public/journey/a%2Fb/map-layers']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { JourneyMapLayersResponse, JourneyPublicMapLayersResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** JRN-04: what a journey map shows beside its entries and photos, for its people and through a share link. */
|
||||
export const journeyMapApi = {
|
||||
layers: (journeyId: number, signal?: AbortSignal): Promise<JourneyMapLayersResponse> =>
|
||||
apiClient.get(`/journeys/${journeyId}/map-layers`, { signal }).then((r) => r.data),
|
||||
publicLayers: (token: string, signal?: AbortSignal): Promise<JourneyPublicMapLayersResponse> =>
|
||||
apiClient.get(`/public/journey/${encodeURIComponent(token)}/map-layers`, { signal }).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
// FE-API-JOURNEY-RECORDINGS-001
|
||||
//
|
||||
// JRN-01: every method pins its verb, its path and its body, and hands back what
|
||||
// the server answered.
|
||||
import { http, HttpResponse, type JsonBodyType } from 'msw';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { journeyRecordingsApi } from './journeyRecordings';
|
||||
|
||||
const BASE = '/api/journeys/7/recordings';
|
||||
const HASH = 'a'.repeat(64);
|
||||
|
||||
let calls: Array<{ method: string; path: string; body: unknown }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
});
|
||||
|
||||
function record(data: JsonBodyType) {
|
||||
return async ({ request }: { request: Request }) => {
|
||||
const text = await request.text();
|
||||
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
|
||||
return HttpResponse.json(data);
|
||||
};
|
||||
}
|
||||
|
||||
describe('journeyRecordingsApi', () => {
|
||||
it('FE-API-JOURNEY-RECORDINGS-001: lists, previews, keeps and removes the recorded days of a journey', async () => {
|
||||
server.use(
|
||||
http.get(BASE, record({ days: [] })),
|
||||
http.post(`${BASE}/preview`, record({ date: '2026-05-14' })),
|
||||
http.put(`${BASE}/2026-05-14`, record({ outcome: 'created' })),
|
||||
http.delete(`${BASE}/2026-05-14`, record({ success: true }))
|
||||
);
|
||||
expect(await journeyRecordingsApi.list(7)).toEqual({ days: [] });
|
||||
expect(
|
||||
await journeyRecordingsApi.preview(7, { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true })
|
||||
).toEqual({ date: '2026-05-14' });
|
||||
expect(await journeyRecordingsApi.save(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH })).toEqual({
|
||||
outcome: 'created',
|
||||
});
|
||||
expect(await journeyRecordingsApi.remove(7, '2026-05-14')).toBeUndefined();
|
||||
expect(calls).toEqual([
|
||||
{ method: 'GET', path: BASE, body: null },
|
||||
{
|
||||
method: 'POST',
|
||||
path: `${BASE}/preview`,
|
||||
body: { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true },
|
||||
},
|
||||
{ method: 'PUT', path: `${BASE}/2026-05-14`, body: { utcOffsetMinutes: 120, expectedHash: HASH } },
|
||||
{ method: 'DELETE', path: `${BASE}/2026-05-14`, body: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-API-JOURNEY-RECORDINGS-002: reviews the stays of a day and takes them over (JRN-02)', async () => {
|
||||
const body = { groups: [{ kind: 'single' as const, stays: [{ suggestionId: 3 }] }], skipSuggestionIds: [4] };
|
||||
server.use(
|
||||
http.post(`${BASE}/2026-05-14/stays/review`, record({ date: '2026-05-14', stays: [] })),
|
||||
http.post(`${BASE}/2026-05-14/stays/import`, record({ entries: [], alreadyTaken: [3], skipped: [] }))
|
||||
);
|
||||
expect(await journeyRecordingsApi.reviewDay(7, '2026-05-14')).toEqual({ date: '2026-05-14', stays: [] });
|
||||
expect(await journeyRecordingsApi.takeOverDay(7, '2026-05-14', body)).toEqual({
|
||||
entries: [],
|
||||
alreadyTaken: [3],
|
||||
skipped: [],
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{ method: 'POST', path: `${BASE}/2026-05-14/stays/review`, body: {} },
|
||||
{ method: 'POST', path: `${BASE}/2026-05-14/stays/import`, body },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-API-JOURNEY-RECORDINGS-003: reads how the journey prepares its days (JRN-03)', async () => {
|
||||
const answer = {
|
||||
enabled: false,
|
||||
connection: 'not_connected',
|
||||
lastAttemptAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
server.use(http.get(`${BASE}/prep`, record(answer)));
|
||||
expect(await journeyRecordingsApi.prep(7)).toEqual(answer);
|
||||
expect(calls).toEqual([{ method: 'GET', path: `${BASE}/prep`, body: null }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
JourneyDayImportRequest,
|
||||
JourneyDayImportResult,
|
||||
JourneyDayStays,
|
||||
JourneyRecordedDaysResponse,
|
||||
JourneyRecordingPrep,
|
||||
JourneyRecordingPreview,
|
||||
JourneyRecordingPreviewRequest,
|
||||
JourneyRecordingSaveRequest,
|
||||
JourneyRecordingSaveResult,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
import { DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich';
|
||||
|
||||
const base = (journeyId: number) => `/journeys/${journeyId}/recordings`;
|
||||
|
||||
/** The preview and the save ask Dawarich first, so they get its longer wait. */
|
||||
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS };
|
||||
|
||||
/**
|
||||
* JRN-01: the recorded days a journey keeps, previewing one, keeping it and dropping it.
|
||||
* JRN-02: the stays of a day reviewed and taken over as drafts.
|
||||
* JRN-03: how the journey prepares its days for the caller.
|
||||
*/
|
||||
export const journeyRecordingsApi = {
|
||||
list: (journeyId: number, signal?: AbortSignal): Promise<JourneyRecordedDaysResponse> =>
|
||||
apiClient.get(base(journeyId), { signal }).then((r) => r.data),
|
||||
prep: (journeyId: number, signal?: AbortSignal): Promise<JourneyRecordingPrep> =>
|
||||
apiClient.get(`${base(journeyId)}/prep`, { signal }).then((r) => r.data),
|
||||
preview: (
|
||||
journeyId: number,
|
||||
body: JourneyRecordingPreviewRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<JourneyRecordingPreview> =>
|
||||
apiClient.post(`${base(journeyId)}/preview`, body, { ...upstream, signal }).then((r) => r.data),
|
||||
save: (journeyId: number, date: string, body: JourneyRecordingSaveRequest): Promise<JourneyRecordingSaveResult> =>
|
||||
apiClient.put(`${base(journeyId)}/${date}`, body, upstream).then((r) => r.data),
|
||||
remove: (journeyId: number, date: string): Promise<void> =>
|
||||
apiClient.delete(`${base(journeyId)}/${date}`).then(() => undefined),
|
||||
reviewDay: (journeyId: number, date: string, signal?: AbortSignal): Promise<JourneyDayStays> =>
|
||||
apiClient.post(`${base(journeyId)}/${date}/stays/review`, {}, { ...upstream, signal }).then((r) => r.data),
|
||||
takeOverDay: (journeyId: number, date: string, body: JourneyDayImportRequest): Promise<JourneyDayImportResult> =>
|
||||
apiClient.post(`${base(journeyId)}/${date}/stays/import`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { skiRouteResponseSchema, type SkiRouteInput, type SkiRouteResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** The piste router: a ski tour's legs planned on the piste data the server keeps, held to the contract. */
|
||||
export const skiRouteApi = {
|
||||
plan: (tripId: number | string, request: SkiRouteInput, signal?: AbortSignal): Promise<SkiRouteResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/ski/route`, request, { signal }).then((r) => skiRouteResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AdminFeatureToggleResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** An admin's switch for a map the Tours planner offers on its operator's terms. */
|
||||
/** An admin's switch for a map or a service the Tours planner offers on its operator's terms. */
|
||||
function tourBaseMapAdminApi(path: string) {
|
||||
return {
|
||||
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get(path).then((r) => r.data),
|
||||
@@ -14,3 +14,5 @@ function tourBaseMapAdminApi(path: string) {
|
||||
export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map');
|
||||
/** BIK-06: CyclOSM for a bike tour. */
|
||||
export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map');
|
||||
/** SKI-02: the piste router, which plans ski tours on the Places host's piste data. */
|
||||
export const skiRoutingAdminApi = tourBaseMapAdminApi('/admin/tours-ski-routing');
|
||||
|
||||
@@ -4,9 +4,10 @@ import apiClient from './client';
|
||||
/**
|
||||
* PLN-02: places along a tour. A search runs several stretches against the map
|
||||
* search on the server, each allowed its own timeout there, so it may take
|
||||
* longer than the shared default.
|
||||
* longer than the shared default: up to three rounds of stretches, each asking
|
||||
* the Overpass mirrors one after another for about twenty seconds at most.
|
||||
*/
|
||||
const POI_SEARCH_TIMEOUT_MS = 60_000;
|
||||
const POI_SEARCH_TIMEOUT_MS = 75_000;
|
||||
|
||||
export const tourPoiSearchApi = {
|
||||
search: (tripId: number | string, body: TourPoiSearchRequest, signal?: AbortSignal): Promise<TourPoiSearchResponse> =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -81,6 +81,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// Capture times landed after a photo import (#1587); the same listener reloads
|
||||
// the journey so the gallery re-sorts.
|
||||
'journey:photos:updated',
|
||||
// A day's recorded route was taken over or removed (JRN-01):
|
||||
// components/Journey/useJourneyRecordings reads the kept days again.
|
||||
'journey:recording:updated',
|
||||
// Studio book — components/Studio/useBookStore (its own listener: a client
|
||||
// with nothing outstanding takes the new version, one with unsaved edits
|
||||
// deliberately does not and conflicts on its next save instead)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -24,23 +24,20 @@ function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
|
||||
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
|
||||
}
|
||||
|
||||
function llmAddon(config: Record<string, unknown> = {}) {
|
||||
function llmAddon(config: Record<string, unknown> = {}, enabled = true) {
|
||||
return buildAddon({
|
||||
id: 'llm_parsing',
|
||||
name: 'AI Parsing',
|
||||
description: 'Extract bookings from files',
|
||||
icon: 'Sparkles',
|
||||
type: 'integration',
|
||||
enabled: true,
|
||||
enabled,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
|
||||
});
|
||||
function aiAddon(id: string, name: string, enabled: boolean) {
|
||||
return buildAddon({ id, name, icon: id === 'mcp' ? 'Terminal' : 'Sparkles', type: 'integration', enabled });
|
||||
}
|
||||
|
||||
/** Every switch is labelled with the thing it controls, top-level tile or shelf
|
||||
@@ -78,7 +75,11 @@ beforeEach(() => {
|
||||
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
|
||||
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false })),
|
||||
// The Tours switches load beside the list; unanswered, each would toast the same error as a failed list.
|
||||
http.get('/api/admin/tours-hiking-map', () => HttpResponse.json({ enabled: false })),
|
||||
http.get('/api/admin/tours-cycle-map', () => HttpResponse.json({ enabled: false })),
|
||||
http.get('/api/admin/tours-ski-routing', () => HttpResponse.json({ enabled: false }))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -572,286 +573,55 @@ describe('AddonManager', () => {
|
||||
expect(links.getAttribute('title')).not.toBe('No shared links yet');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
|
||||
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('AI Parsing');
|
||||
// The catalog key now exists, so the tile shows the translation rather than falling
|
||||
// back to the English description the server sent.
|
||||
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
const label = await screen.findByText('Provider');
|
||||
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
|
||||
const tile = label.closest('article');
|
||||
expect(tile).not.toBeNull();
|
||||
expect(tile!.textContent).toContain('AI Parsing');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
await screen.findByRole('button', { name: 'llama3:8b' });
|
||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
|
||||
|
||||
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
it('FE-ADMIN-ADDON-020: the AI addons head the integration column as one tile with a switch each', async () => {
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? HttpResponse.json({ error: 'down' }, { status: 500 })
|
||||
: HttpResponse.json({ models: [] });
|
||||
}),
|
||||
addonsRoute([
|
||||
aiAddon('mcp', 'MCP', false),
|
||||
llmAddon({}, false),
|
||||
aiAddon('ai_assistant', 'AI Assistant', false),
|
||||
buildAddon({ id: 'airtrail', name: 'AirTrail', type: 'integration', icon: 'Plane' }),
|
||||
]),
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
// The server's reason is shown, not axios' status line (#2649)
|
||||
await screen.findByText('down');
|
||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
expect(calls).toBe(2);
|
||||
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
|
||||
const switches = within(tile).getAllByRole('button').map((b) => b.getAttribute('aria-label'));
|
||||
// In the tile's order, whatever order the server sent them in.
|
||||
expect(switches).toEqual(['AI Assistant', 'AI Parsing', 'MCP']);
|
||||
expect(within(tile).getByText('0/3')).toBeInTheDocument();
|
||||
// Their own icons, keyed by id: the icon column of an install still says Sparkles or Terminal.
|
||||
expect(tile.querySelector('svg.lucide-messages-square')).toBeInTheDocument();
|
||||
expect(tile.querySelector('svg.lucide-scan-text')).toBeInTheDocument();
|
||||
expect(tile.querySelector('svg.lucide-cable')).toBeInTheDocument();
|
||||
// Nothing to set up while neither the assistant nor parsing is on.
|
||||
expect(within(tile).queryByRole('button', { name: 'Set up AI' })).toBeNull();
|
||||
// AirTrail is still its own tile in the same column.
|
||||
expect(screen.getByText('AirTrail').closest('article')).not.toBe(tile);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
|
||||
it('FE-ADMIN-ADDON-021: with parsing on, the tile offers the settings dialog, which closes again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
|
||||
server.use(
|
||||
addonsRoute([llmAddon({}), aiAddon('ai_assistant', 'AI Assistant', false), aiAddon('mcp', 'MCP', true)]),
|
||||
http.get('/api/admin/ai/settings', () =>
|
||||
HttpResponse.json({ policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }),
|
||||
),
|
||||
http.get('/api/admin/ai/connection', () => HttpResponse.json({ connection: null, prefill: null })),
|
||||
http.get('/api/ai/status', () => HttpResponse.json({ error: 'not needed' }, { status: 404 })),
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
||||
|
||||
// A hand-typed base URL is used for the next lookup on blur
|
||||
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
|
||||
await user.tab();
|
||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
|
||||
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
|
||||
expect(within(tile).getByText('2/3')).toBeInTheDocument();
|
||||
await user.click(within(tile).getByRole('button', { name: 'Set up AI' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole('button', { name: 'AI Assistant' })).toBeNull();
|
||||
const closes = within(dialog).getAllByRole('button', { name: 'Close' });
|
||||
await user.click(closes[closes.length - 1]);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
|
||||
const user = userEvent.setup();
|
||||
let pulled: unknown = null;
|
||||
let modelCalls = 0;
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('Pulling…');
|
||||
expect(screen.getByText('starting…')).toBeInTheDocument();
|
||||
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
await screen.findByText('no disk space');
|
||||
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Failed to save');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
|
||||
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
// The model is trimmed before it is stored, the key is sent verbatim
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
|
||||
modelsRoute([]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
|
||||
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-038: whether the model reads images is auto, on or off, kept as stored and saved as chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'off' })]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
// Stored off stays off; a cloud provider is told what Automatic means for it.
|
||||
await user.click(await screen.findByRole('button', { name: 'No' }));
|
||||
expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toMatchObject({ config: { provider: 'openai', vision: 'on' } });
|
||||
expect(bodies[0]).not.toHaveProperty('config.multimodal');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([]));
|
||||
render(<AddonManager />);
|
||||
expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
)),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('manifest not found');
|
||||
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
||||
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Provider');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
|
||||
expect(urls).toHaveLength(0);
|
||||
});
|
||||
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
@@ -897,6 +667,10 @@ describe('AddonManager', () => {
|
||||
</>
|
||||
);
|
||||
|
||||
// Folded like Collab's and Documents' options until the bar is opened.
|
||||
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
|
||||
await unfoldShelves();
|
||||
const toggle = await screen.findByRole('button', { name: 'wanderer' });
|
||||
expect(isOn(toggle)).toBe(false);
|
||||
await userEvent.click(toggle);
|
||||
@@ -917,6 +691,7 @@ describe('AddonManager', () => {
|
||||
</>
|
||||
);
|
||||
|
||||
await unfoldShelves();
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
|
||||
|
||||
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useId, type ComponentType } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
@@ -12,11 +10,10 @@ import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
|
||||
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel'
|
||||
import { useAddonManager } from './addons/useAddonManager'
|
||||
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
|
||||
import AiAddonTile from './addons/AiAddonTile'
|
||||
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
|
||||
import WandererMark from '../Tours/wanderer/WandererMark'
|
||||
|
||||
@@ -74,7 +71,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const { t } = useTranslation()
|
||||
const dark = useIsDark()
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
@@ -154,33 +151,29 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
})
|
||||
}
|
||||
// An array, not a fragment: the tile counts its rows to fold them behind the
|
||||
// "N options" bar the way Collab's and Documents' are.
|
||||
if (addon.id === 'tours' && addon.enabled) {
|
||||
return (
|
||||
<>
|
||||
return [
|
||||
<AddonSubRow
|
||||
key="wanderer"
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>,
|
||||
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<AddonSubRow
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
key={key}
|
||||
icon={<Icon size={14} />}
|
||||
title={t(titleKey)}
|
||||
description={t(subtitleKey)}
|
||||
enabled={enabled}
|
||||
onToggle={() => void toggle()}
|
||||
/>
|
||||
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<AddonSubRow
|
||||
key={key}
|
||||
icon={<Icon size={14} />}
|
||||
title={t(titleKey)}
|
||||
description={t(subtitleKey)}
|
||||
enabled={enabled}
|
||||
onToggle={() => void toggle()}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
// The AI-parsing settings hang off their tile like every other sub-feature,
|
||||
// just as a form instead of toggle rows.
|
||||
if (addon.id === 'llm_parsing' && addon.enabled) {
|
||||
return <LlmParsingConfig addon={addon} />
|
||||
)),
|
||||
]
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -209,9 +202,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
{ key: 'integration', addons: integrationAddons },
|
||||
].filter(g => g.addons.length > 0)
|
||||
].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0))
|
||||
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + aiAddons.length
|
||||
|
||||
const subtitle = (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
@@ -242,8 +235,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
|
||||
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
|
||||
<div className="divide-y divide-edge-faint">
|
||||
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
|
||||
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
|
||||
{g.addons.map(tile)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
@@ -282,140 +279,3 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config. When set, applies to the whole instance and
|
||||
* overrides per-user config (see server llmConfig.ts). The API key is masked on
|
||||
* read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider, it also lists installed Ollama models and can pull NuExtract models.
|
||||
*/
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
|
||||
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
|
||||
} = useLlmParsingConfig(addon)
|
||||
const fieldId = useId()
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
||||
]
|
||||
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
|
||||
|
||||
/* Lives in the tile's shelf like the collab toggles, so it is one column of
|
||||
eyebrow fields: the band this used to be had a whole page width. */
|
||||
return (
|
||||
<li className={`flex flex-col gap-3 p-3 ${saving ? 'opacity-60' : ''}`}>
|
||||
<SettingsHint>
|
||||
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
|
||||
</SettingsHint>
|
||||
|
||||
<EditorField label="Provider">
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</EditorField>
|
||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label="API key"
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
|
||||
>
|
||||
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</EditorField>
|
||||
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
|
||||
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</EditorField>
|
||||
|
||||
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
|
||||
<Segmented<LlmVision>
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
value={vision}
|
||||
onChange={v => setVision(asLlmVision(v))}
|
||||
options={visionOptions}
|
||||
fill
|
||||
/>
|
||||
</EditorField>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels}
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||
style={fs(11.5, 'body')}>
|
||||
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
aria-pressed={model === name}
|
||||
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
|
||||
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPulling && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ChevronRight, Settings2, Sparkles } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { StatusPill } from '../../Settings/settingsKit';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import AddonSubRow from '../AddonSubRow';
|
||||
import { type Addon, getAddonLabel } from './addonModel';
|
||||
import { AI_ADDON_ICONS, type AiAddonId } from './aiAddons';
|
||||
import AiSettingsDialog from './AiAdminConfig';
|
||||
import { useAiAddonTile } from './useAiAddonTile';
|
||||
|
||||
/**
|
||||
* The AI addons as one tile at the head of the integration column: the
|
||||
* assistant, AI parsing and MCP as switches in its shelf, and one button to
|
||||
* the dialog that holds their settings, so a page with all three on stays as
|
||||
* short as one with none.
|
||||
*/
|
||||
export default function AiAddonTile({
|
||||
addons,
|
||||
onToggle,
|
||||
}: {
|
||||
addons: Addon[];
|
||||
onToggle: (addon: Addon) => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const tile = useAiAddonTile(addons);
|
||||
const anyOn = tile.enabledCount > 0;
|
||||
return (
|
||||
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-[10px] transition-colors ${
|
||||
anyOn
|
||||
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
|
||||
: 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-px">
|
||||
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{t('admin.addons.ai.name')}
|
||||
</h4>
|
||||
<p
|
||||
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${anyOn ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('admin.addons.ai.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-none pt-1">
|
||||
<StatusPill tone={anyOn ? 'success' : 'neutral'}>
|
||||
{tile.enabledCount}/{addons.length}
|
||||
</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
|
||||
{addons.map((addon) => {
|
||||
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
|
||||
const label = getAddonLabel(t, addon);
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={addon.id}
|
||||
icon={<Icon size={14} />}
|
||||
title={label.name}
|
||||
description={label.description}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => onToggle(addon)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{tile.canSetUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tile.openDialog}
|
||||
className="flex min-h-[44px] w-full items-center gap-2.5 border-t border-edge-faint px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
|
||||
<Settings2 size={14} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.addons.ai.setup')}
|
||||
</span>
|
||||
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tile.dialogOpen && <AiSettingsDialog addons={addons} onClose={tile.closeDialog} />}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-020: the AI settings dialog behind the AI tile.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { ToastContainer } from '../../shared/Toast';
|
||||
import type { Addon } from './addonModel';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
adminSettings: vi.fn(),
|
||||
saveAdminSettings: vi.fn(),
|
||||
adminConnection: vi.fn(),
|
||||
saveAdminConnection: vi.fn(),
|
||||
removeAdminConnection: vi.fn(),
|
||||
adminModels: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
|
||||
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'openai',
|
||||
model: 'gpt-5.5',
|
||||
hint: '…9f3c',
|
||||
status: 'ok',
|
||||
host: 'api.openai.com',
|
||||
baseUrl: null,
|
||||
},
|
||||
prefill: null,
|
||||
};
|
||||
|
||||
function addon(id: string, enabled: boolean, config?: Record<string, unknown>): Addon {
|
||||
return { id, name: id, description: '', icon: 'Sparkles', type: 'integration', enabled, config } as Addon;
|
||||
}
|
||||
|
||||
const ASSISTANT = addon('ai_assistant', true);
|
||||
const parsing = (config: Record<string, unknown> = {}) => addon('llm_parsing', true, config);
|
||||
|
||||
function renderDialog(addons: Addon[] = [ASSISTANT]) {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AiSettingsDialog addons={addons} onClose={onClose} />
|
||||
</>
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
/** The dialog opened on AI parsing's tab. */
|
||||
async function renderParsing(config: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup();
|
||||
renderDialog([ASSISTANT, parsing(config)]);
|
||||
await user.click(await screen.findByRole('button', { name: 'AI Parsing' }));
|
||||
return user;
|
||||
}
|
||||
|
||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
|
||||
});
|
||||
}
|
||||
|
||||
function saveRoute(bodies: unknown[], fail = false) {
|
||||
return http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return fail ? HttpResponse.error() : HttpResponse.json({ success: true });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
repo.adminConnection.mockResolvedValue({ connection: null, prefill: null });
|
||||
repo.removeAdminConnection.mockResolvedValue(undefined);
|
||||
repo.adminModels.mockResolvedValue([{ id: 'gpt-5.5', label: 'GPT-5.5', supportsTools: true }]);
|
||||
});
|
||||
|
||||
describe('the connection and the assistant', () => {
|
||||
it('FE-COMP-AI-ADMIN-001: shows who pays and the setup for everyone, the limits on the assistant tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
|
||||
// The admin may share their own ChatGPT plan with everyone, so its tile is offered too.
|
||||
expect(screen.getByRole('button', { name: /Sign in with ChatGPT/ })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
||||
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
|
||||
expect(screen.getByText('Keep chats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' }));
|
||||
await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument());
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
|
||||
expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
||||
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => {
|
||||
const user = userEvent.setup();
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
renderDialog();
|
||||
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
|
||||
expect(screen.getByText('…9f3c')).toBeInTheDocument();
|
||||
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Change' }));
|
||||
expect(screen.getByText('What would you like to chat with?')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-004: removing the connection asks first', async () => {
|
||||
const user = userEvent.setup();
|
||||
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue({ connection: null, prefill: null });
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('button', { name: 'Remove connection' }));
|
||||
expect(screen.getByText('Disconnect OpenAI? Your chats stay.')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button', { name: 'Remove connection' });
|
||||
await user.click(buttons[buttons.length - 1]);
|
||||
expect(await screen.findByText('No connection yet.')).toBeInTheDocument();
|
||||
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-005: a tab shows only while its addon is on, and Close closes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = renderDialog([addon('ai_assistant', false), parsing()]);
|
||||
expect(await screen.findByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'AI Assistant' })).not.toBeInTheDocument();
|
||||
const closes = screen.getAllByRole('button', { name: 'Close' });
|
||||
await user.click(closes[closes.length - 1]);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI parsing', () => {
|
||||
it('FE-COMP-AI-ADMIN-010: without its own connection parsing reads with the shared one, and can be given its own', async () => {
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
server.use(modelsRoute([]));
|
||||
const user = await renderParsing({ vision: 'auto' });
|
||||
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.5')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Use a different one' }));
|
||||
expect(screen.getByLabelText('Model')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-011: with no connection anywhere it says so and leads to the connection tab', async () => {
|
||||
const user = await renderParsing();
|
||||
expect(
|
||||
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
|
||||
).toBeInTheDocument();
|
||||
// The link in the hint, not the tab of the same name above it.
|
||||
const link = screen.getAllByRole('button', { name: 'Connection' }).find((b) => !b.hasAttribute('aria-pressed'));
|
||||
await user.click(link as HTMLElement);
|
||||
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-012: an own connection is shown as such and goes back to the shared one, keeping the vision setting', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'anthropic', model: 'claude-x', apiKey: '••••••••', vision: 'on' });
|
||||
expect(await screen.findByText('Own connection', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByText('claude-x')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Use the shared connection' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({ config: { vision: 'on' } });
|
||||
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-013: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||
const user = await renderParsing({ provider: 'local', model: 'old' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(await screen.findByRole('button', { name: 'llama3:8b' }));
|
||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('llama3:8b');
|
||||
// qwen3.5:4b is installed already, so the recommended row offers Use instead of Pull.
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("FE-COMP-AI-ADMIN-014: an unreachable Ollama shows the server's reason and Refresh retries", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
|
||||
})
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('down');
|
||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-015: switching providers swaps the address placeholder and the Ollama block, and drops a stale address', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'local', model: 'm', baseUrl: 'http://ollama.lan:11434/v1' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toHaveValue('');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('radio', { name: 'Anthropic' }));
|
||||
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
await user.clear(screen.getByLabelText('Model'));
|
||||
await user.type(screen.getByLabelText('Model'), ' claude-haiku ');
|
||||
await user.type(screen.getByLabelText('API key'), 'sk-ant-live');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
// The stale local address must not ride along to Anthropic, and the model is trimmed.
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'anthropic', model: 'claude-haiku', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-016: a hand-typed address is asked on blur, a cloud provider asks nothing', async () => {
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(modelsRoute([], urls));
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.type(screen.getByLabelText('Base URL'), 'http://ollama.lan:11434/v1');
|
||||
await user.tab();
|
||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||
const before = urls.length;
|
||||
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
||||
await user.type(screen.getByLabelText('Base URL'), 'https://proxy.local/v1');
|
||||
await user.tab();
|
||||
expect(urls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-017: a pull streams progress and then selects the model', async () => {
|
||||
let pulled: unknown = null;
|
||||
let modelCalls = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } }
|
||||
);
|
||||
})
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
expect(await screen.findAllByText('Pulling…')).not.toHaveLength(0);
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-018: an error frame or a refused pull is reported and the button comes back', async () => {
|
||||
server.use(
|
||||
modelsRoute([]),
|
||||
http.post(
|
||||
'/api/admin/llm/local/pull',
|
||||
() =>
|
||||
new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', {
|
||||
headers: { 'Content-Type': 'application/x-ndjson' },
|
||||
})
|
||||
)
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
await screen.findByText('manifest not found');
|
||||
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-019: saving keeps a masked key and reports both outcomes', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
||||
});
|
||||
server.use(saveRoute(bodies, true));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Could not save AI settings');
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-020: whether the model reads images saves at once, with the connection as saved', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(saveRoute(bodies));
|
||||
const user = await renderParsing({
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1-mini',
|
||||
baseUrl: '',
|
||||
apiKey: 'sk',
|
||||
vision: 'off',
|
||||
});
|
||||
expect(await screen.findByRole('button', { name: 'No' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'on' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { Check, Loader2, Sparkles } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import CustomSelect from '../../shared/CustomSelect';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../../shared/DialogShell';
|
||||
import { NumericInput } from '../../shared/NumericInput';
|
||||
import { EditorField, INPUT, Segmented } from '../../shared/dialogParts';
|
||||
import ParsingSettings from './ParsingSettings';
|
||||
import type { Addon } from './addonModel';
|
||||
import { POLICY_CHOICES, useAiAdminConfig } from './useAiAdminConfig';
|
||||
|
||||
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
|
||||
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint';
|
||||
|
||||
/** Who pays, as three rows to pick one from: the labels are too long for a segmented track in a shelf. */
|
||||
function PolicyChoice({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={EYEBROW} style={fs(9.5)}>
|
||||
{t('assistant.admin.policy')}
|
||||
</span>
|
||||
<div role="radiogroup" aria-label={t('assistant.admin.policy')} className="flex flex-col gap-1.5">
|
||||
{POLICY_CHOICES.map((choice) => {
|
||||
const on = A.policy === choice.value;
|
||||
return (
|
||||
<button
|
||||
key={choice.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
onClick={() => A.setPolicy(choice.value)}
|
||||
className={`flex items-center gap-2 rounded-[10px] border px-3 py-2 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 font-medium ${on ? 'text-content' : 'text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(choice.labelKey)}
|
||||
</span>
|
||||
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-content" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{A.policyHintKey && <SettingsHint>{t(A.policyHintKey)}</SettingsHint>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The connection everyone runs on, once set up: who answers, its model, and replacing or removing it. */
|
||||
function LiveConnection({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const { live, provider, models, busy, setModel, replace, askRemove } = A.instance;
|
||||
if (!live) return null;
|
||||
const status = CONNECTION_STATUS[live.status];
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-3 rounded-[12px] border border-edge-faint bg-surface-card p-3 ${busy ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ProviderMark kind={live.kind} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{provider}
|
||||
</p>
|
||||
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
|
||||
{live.hint ?? live.host}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill tone={status.tone}>{t(status.labelKey)}</StatusPill>
|
||||
</div>
|
||||
{live.status === 'ok' && (
|
||||
<EditorField label={t('assistant.connect.model')} hint={t('assistant.connect.modelHint')}>
|
||||
<CustomSelect
|
||||
ariaLabel={t('assistant.connect.model')}
|
||||
value={live.model}
|
||||
onChange={(v) => setModel(String(v))}
|
||||
options={models}
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
searchable={models.length > 8}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={replace} disabled={busy} className={SETTINGS_BUTTON}>
|
||||
{t('common.change')}
|
||||
</button>
|
||||
<button type="button" onClick={askRemove} disabled={busy} className={SETTINGS_BUTTON_DANGER}>
|
||||
{t('assistant.admin.connectionRemove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceConnection({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const { live, connection, replacing, cancelReplace, onConnected } = A.instance;
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<span className={EYEBROW} style={fs(9.5)}>
|
||||
{t('assistant.admin.connection')}
|
||||
</span>
|
||||
{!replacing && <LiveConnection A={A} />}
|
||||
{!live && <SettingsHint>{t('assistant.admin.connectionNone')}</SettingsHint>}
|
||||
{A.showPanel && (
|
||||
<AiConnectPanel scope="instance" onConnected={onConnected} prefill={connection?.prefill ?? null} />
|
||||
)}
|
||||
{replacing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelReplace}
|
||||
className={`${SETTINGS_BUTTON} self-start`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
|
||||
function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{A.instancePays && (
|
||||
<EditorField
|
||||
label={t('assistant.admin.dailyLimit')}
|
||||
htmlFor={`${fieldId}-daily`}
|
||||
hint={t('assistant.admin.dailyLimitHint')}
|
||||
>
|
||||
<NumericInput
|
||||
id={`${fieldId}-daily`}
|
||||
mode="integer"
|
||||
className={`${INPUT} tabular-nums`}
|
||||
value={A.dailyDraft}
|
||||
onValueChange={A.setDailyDraft}
|
||||
onBlur={A.commitDailyLimit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
<EditorField label={t('assistant.admin.retention')}>
|
||||
<CustomSelect
|
||||
ariaLabel={t('assistant.admin.retention')}
|
||||
value={A.retention}
|
||||
onChange={(v) => A.setRetention(String(v))}
|
||||
options={A.retentionOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AiTab = 'connection' | 'assistant' | 'parsing';
|
||||
|
||||
/**
|
||||
* The AI settings behind the AI tile's button, in one dialog so the addon page
|
||||
* stays short: the connection the assistant and AI parsing share (and who pays
|
||||
* for it), the assistant's limits, and how bookings are read. A tab shows only
|
||||
* while its addon is on. Each change saves at once.
|
||||
*/
|
||||
export default function AiSettingsDialog({
|
||||
addons,
|
||||
onClose,
|
||||
}: {
|
||||
addons: Addon[];
|
||||
onClose: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const A = useAiAdminConfig();
|
||||
const assistantOn = addons.some((a) => a.id === 'ai_assistant' && a.enabled);
|
||||
const parsing = addons.find((a) => a.id === 'llm_parsing' && a.enabled) ?? null;
|
||||
const tabs: { value: AiTab; label: string }[] = [
|
||||
{ value: 'connection', label: t('admin.addons.ai.tab.connection') },
|
||||
...(assistantOn ? [{ value: 'assistant' as const, label: t('admin.addons.catalog.ai_assistant.name') }] : []),
|
||||
...(parsing ? [{ value: 'parsing' as const, label: t('admin.addons.catalog.llm_parsing.name') }] : []),
|
||||
];
|
||||
const [tab, setTab] = useState<AiTab>('connection');
|
||||
const shown = tabs.some((x) => x.value === tab) ? tab : 'connection';
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="detail"
|
||||
align="top"
|
||||
blocked={A.instance.confirmingRemove}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<Sparkles size={20} strokeWidth={2} />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
eyebrow={t('admin.addons.ai.name')}
|
||||
title={t('admin.addons.ai.setup')}
|
||||
sub={t('admin.addons.ai.dialogSub')}
|
||||
subWraps
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
{tabs.length > 1 && (
|
||||
<Segmented<AiTab> label={t('admin.addons.ai.setup')} value={shown} onChange={setTab} options={tabs} fill />
|
||||
)}
|
||||
{!A.loaded ? (
|
||||
<div className="grid place-items-center py-10">
|
||||
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{shown === 'connection' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PolicyChoice A={A} />
|
||||
{A.instancePays && <InstanceConnection A={A} />}
|
||||
</div>
|
||||
)}
|
||||
{shown === 'assistant' && <AssistantLimits A={A} />}
|
||||
{shown === 'parsing' && parsing && (
|
||||
<ParsingSettings addon={parsing} A={A} onOpenConnection={() => setTab('connection')} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={A.instance.confirmingRemove}
|
||||
onClose={A.instance.cancelRemove}
|
||||
onConfirm={A.instance.remove}
|
||||
title={t('assistant.admin.connectionRemove')}
|
||||
message={t('assistant.connect.disconnectConfirm', { provider: A.instance.provider })}
|
||||
confirmLabel={t('assistant.admin.connectionRemove')}
|
||||
danger
|
||||
/>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared';
|
||||
import { Check, RefreshCw } from 'lucide-react';
|
||||
import React, { useId } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import { DialogSection, fs } from '../../shared/DialogShell';
|
||||
import { EditorField, INPUT, PANEL, Segmented } from '../../shared/dialogParts';
|
||||
import { type Addon, ANTHROPIC_HINT, MASKED, RECOMMENDED_MODELS } from './addonModel';
|
||||
import type { useAiAdminConfig } from './useAiAdminConfig';
|
||||
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
||||
|
||||
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
|
||||
type Parsing = ReturnType<typeof useLlmParsingConfig>;
|
||||
type MarkKind = Parameters<typeof ProviderMark>[0]['kind'];
|
||||
|
||||
/** Parsing's own providers, with the mark the assistant shows for each. */
|
||||
const OWN_PROVIDERS = [
|
||||
{ value: 'local', labelKey: 'settings.aiParsing.providerLocal', mark: 'custom' },
|
||||
{ value: 'openai', labelKey: 'settings.aiParsing.providerOpenai', mark: 'openai' },
|
||||
{ value: 'anthropic', labelKey: 'settings.aiParsing.providerAnthropic', mark: 'anthropic' },
|
||||
] as const;
|
||||
|
||||
const markOf = (provider: string): MarkKind => OWN_PROVIDERS.find((p) => p.value === provider)?.mark ?? 'custom';
|
||||
|
||||
/** The connection parsing reads with, as one line: a mark, who answers, the model and where it comes from. */
|
||||
function ReadsWith({ mark, title, model, tag }: { mark: MarkKind; title: string; model: string; tag: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||
<ProviderMark kind={mark} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{title}
|
||||
</p>
|
||||
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
|
||||
{model}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill>{tag}</StatusPill>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Which connection parsing reads with now, and the way to the other one. */
|
||||
function ParsingSource({ A, P, onOpenConnection }: { A: AiAdmin; P: Parsing; onOpenConnection: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const live = A.instancePays ? A.instance.live : null;
|
||||
if (P.ownConnection) {
|
||||
const own = OWN_PROVIDERS.find((p) => p.value === P.savedProvider);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<ReadsWith
|
||||
mark={markOf(P.savedProvider)}
|
||||
title={own ? t(own.labelKey) : P.savedProvider}
|
||||
model={P.savedModel}
|
||||
tag={t('admin.addons.ai.parsing.own')}
|
||||
/>
|
||||
<SettingsHint>{t('admin.addons.ai.parsing.ownHint')}</SettingsHint>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.switchToShared()}
|
||||
disabled={P.saving}
|
||||
className={`${SETTINGS_BUTTON} self-start`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.addons.ai.parsing.useShared')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{live ? (
|
||||
<ReadsWith
|
||||
mark={live.kind}
|
||||
title={A.instance.provider}
|
||||
model={live.model}
|
||||
tag={t('admin.addons.ai.parsing.sharedTag')}
|
||||
/>
|
||||
) : (
|
||||
<SettingsHint>
|
||||
{t(A.policy === 'user' ? 'admin.addons.ai.parsing.perUser' : 'admin.addons.ai.parsing.none')}
|
||||
</SettingsHint>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
|
||||
{!live && A.policy !== 'user' && (
|
||||
<button type="button" onClick={onOpenConnection} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
{t('admin.addons.ai.tab.connection')}
|
||||
</button>
|
||||
)}
|
||||
{!P.formOpen && (
|
||||
<button type="button" onClick={P.startOwn} className={SETTINGS_BUTTON}>
|
||||
{t('admin.addons.ai.parsing.setupOwn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The models on the local server to pick from, and the recommended ones to pull. */
|
||||
function LocalModels({ P }: { P: Parsing }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={PANEL}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-geist font-bold uppercase tracking-[.08em] text-content-faint"
|
||||
style={fs(9.5)}
|
||||
>
|
||||
{t('admin.addons.ai.parsing.installed')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.loadModels()}
|
||||
disabled={P.loadingModels}
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-card hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<RefreshCw size={11} strokeWidth={2.2} className={P.loadingModels ? 'animate-spin' : undefined} />
|
||||
{t('admin.addons.ai.parsing.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
{P.modelsErr && (
|
||||
<p className="m-0 break-words text-danger" style={fs(11.5)}>
|
||||
{P.modelsErr}
|
||||
</p>
|
||||
)}
|
||||
{!P.modelsErr && P.installed.length === 0 && !P.loadingModels && (
|
||||
<SettingsHint>{t('admin.addons.ai.parsing.noModels')}</SettingsHint>
|
||||
)}
|
||||
{P.installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{P.installed.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => P.setModel(name)}
|
||||
aria-pressed={P.model === name}
|
||||
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${P.model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
|
||||
{t('admin.addons.ai.parsing.pullTitle')}
|
||||
</span>
|
||||
{RECOMMENDED_MODELS.map((m) => {
|
||||
const here = P.isInstalled(m.id);
|
||||
const chosen = P.model === m.id;
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{m.label}
|
||||
</span>
|
||||
{m.recommended && <StatusPill tone="success">{t('assistant.model.recommended')}</StatusPill>}
|
||||
{here ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => P.setModel(m.id)}
|
||||
disabled={chosen}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{chosen ? <Check size={13} strokeWidth={2.4} /> : null}
|
||||
{t(chosen ? 'admin.addons.ai.parsing.selected' : 'admin.addons.ai.parsing.use')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.pull(m.id)}
|
||||
disabled={!!P.pulling}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t(P.pulling === m.id ? 'admin.addons.ai.parsing.pulling' : 'admin.addons.ai.parsing.pull')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{P.pulling === m.id && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-200"
|
||||
style={{ width: `${P.pullPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{P.pullStatus}
|
||||
{P.pullPct ? ` ${P.pullPct}%` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A connection of parsing's own: the provider as tiles, its address, key and model. */
|
||||
function OwnConnectionForm({ P }: { P: Parsing }) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
return (
|
||||
<div className={`flex flex-col gap-3 ${P.saving ? 'opacity-60' : ''}`}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('settings.aiParsing.provider')}
|
||||
className="grid grid-cols-3 gap-2 max-sm:grid-cols-1"
|
||||
>
|
||||
{OWN_PROVIDERS.map((p) => {
|
||||
const on = P.provider === p.value;
|
||||
return (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
onClick={() => P.setProvider(p.value)}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-[12px] border p-2.5 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
|
||||
>
|
||||
<ProviderMark kind={p.mark} size="sm" />
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate font-medium ${on ? 'text-content' : 'text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(p.labelKey)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<EditorField
|
||||
label={t('settings.aiParsing.baseUrl')}
|
||||
htmlFor={`${fieldId}-url`}
|
||||
hint={t('settings.aiParsing.baseUrlHint')}
|
||||
>
|
||||
<input
|
||||
id={`${fieldId}-url`}
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
className={INPUT}
|
||||
value={P.baseUrl}
|
||||
onChange={(e) => P.setBaseUrl(e.target.value)}
|
||||
onBlur={() => void P.loadModels()}
|
||||
placeholder={P.baseUrlPlaceholder}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label={t('settings.aiParsing.apiKey')}
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={P.provider === 'anthropic' ? ANTHROPIC_HINT : t('settings.aiParsing.apiKeyHint')}
|
||||
>
|
||||
<input
|
||||
id={`${fieldId}-key`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className={INPUT}
|
||||
value={P.apiKey}
|
||||
onChange={(e) => P.setApiKey(e.target.value)}
|
||||
placeholder={
|
||||
P.apiKey === MASKED ? MASKED : P.provider === 'local' ? t('admin.addons.ai.parsing.apiKeyOptional') : ''
|
||||
}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('settings.aiParsing.model')} htmlFor={`${fieldId}-model`}>
|
||||
<input
|
||||
id={`${fieldId}-model`}
|
||||
autoComplete="off"
|
||||
className={`${INPUT} font-geist`}
|
||||
value={P.model}
|
||||
onChange={(e) => P.setModel(e.target.value)}
|
||||
/>
|
||||
</EditorField>
|
||||
{P.provider === 'local' && <LocalModels P={P} />}
|
||||
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
|
||||
{!P.ownConnection && (
|
||||
<button type="button" onClick={P.cancelOwn} disabled={P.saving} className={SETTINGS_BUTTON}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.save()}
|
||||
disabled={P.saving || !P.model.trim()}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
{t(P.saving ? 'common.saving' : 'common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI parsing in the AI dialog: which connection reads the bookings (the shared
|
||||
* one, or one of its own an older install kept), a connection of its own when
|
||||
* wanted, and whether the model reads images. Each part saves on its own.
|
||||
*/
|
||||
export default function ParsingSettings({
|
||||
addon,
|
||||
A,
|
||||
onOpenConnection,
|
||||
}: {
|
||||
addon: Addon;
|
||||
A: AiAdmin;
|
||||
onOpenConnection: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const P = useLlmParsingConfig(addon);
|
||||
const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) }));
|
||||
const local =
|
||||
(P.ownConnection ? P.savedProvider : A.instance.live?.kind === 'custom' ? 'local' : 'cloud') === 'local';
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<DialogSection label={t('admin.addons.ai.parsing.readsWith')}>
|
||||
<ParsingSource A={A} P={P} onOpenConnection={onOpenConnection} />
|
||||
</DialogSection>
|
||||
{P.formOpen && (
|
||||
<DialogSection label={t('admin.addons.ai.parsing.own')}>
|
||||
<OwnConnectionForm P={P} />
|
||||
</DialogSection>
|
||||
)}
|
||||
<DialogSection label={t('settings.aiParsing.multimodal')}>
|
||||
<Segmented<LlmVision>
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
value={P.vision}
|
||||
onChange={(v) => P.setVision(asLlmVision(v))}
|
||||
options={visionOptions}
|
||||
fill
|
||||
/>
|
||||
<div className="mt-1.5">
|
||||
<SettingsHint>
|
||||
{t(local ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
|
||||
</SettingsHint>
|
||||
</div>
|
||||
</DialogSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Cable, type LucideIcon, MessagesSquare, ScanText } from 'lucide-react';
|
||||
import type { Addon } from './addonModel';
|
||||
|
||||
/**
|
||||
* The three addons that talk to a language model, shown as one AI tile in the
|
||||
* integration column: the assistant in the trip, AI parsing (booking import)
|
||||
* and MCP (TREK inside other AI apps). Their ids, rows and settings stay their
|
||||
* own; only the page groups them. Assistant and parsing share one connection.
|
||||
*/
|
||||
const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const;
|
||||
export type AiAddonId = (typeof AI_ADDON_IDS)[number];
|
||||
|
||||
/** Each one's glyph, keyed by id: the icon column of an existing install still holds what it was seeded with. */
|
||||
export const AI_ADDON_ICONS: Record<AiAddonId, LucideIcon> = {
|
||||
ai_assistant: MessagesSquare,
|
||||
llm_parsing: ScanText,
|
||||
mcp: Cable,
|
||||
};
|
||||
|
||||
export function isAiAddon(addon: Addon): boolean {
|
||||
return (AI_ADDON_IDS as readonly string[]).includes(addon.id);
|
||||
}
|
||||
|
||||
/** The AI addons this install has, in the tile's order. */
|
||||
export function aiAddonsOf(addons: Addon[]): Addon[] {
|
||||
return AI_ADDON_IDS.flatMap((id) => addons.find((a) => a.id === id) ?? []);
|
||||
}
|
||||
|
||||
/** Whether the shared connection is used at all: the assistant or AI parsing is on. */
|
||||
export function usesConnection(addons: Addon[]): boolean {
|
||||
return addons.some((a) => (a.id === 'ai_assistant' || a.id === 'llm_parsing') && a.enabled);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
|
||||
import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps';
|
||||
import { wandererAdminApi } from '../../../api/wanderer';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import type { Addon } from './addonModel';
|
||||
@@ -26,6 +26,8 @@ const ADDONS = [
|
||||
addon('llm_parsing', 'integration', false),
|
||||
addon('immich', 'photo_provider', false),
|
||||
addon('gdrive', 'document_provider'),
|
||||
addon('airtrail', 'integration', false),
|
||||
addon('mcp', 'integration'),
|
||||
];
|
||||
|
||||
let loadAddons: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
@@ -40,6 +42,7 @@ beforeEach(() => {
|
||||
vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false });
|
||||
vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true });
|
||||
vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true });
|
||||
vi.spyOn(skiRoutingAdminApi, 'get').mockResolvedValue({ enabled: true });
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -52,21 +55,23 @@ async function loaded() {
|
||||
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
|
||||
|
||||
/** One of the map switches under Tours, by its key. */
|
||||
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle') {
|
||||
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle' | 'ski') {
|
||||
const found = manager.mapSwitches.find((entry) => entry.key === key);
|
||||
if (!found) throw new Error(`no ${key} map switch`);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('useAddonManager', () => {
|
||||
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
|
||||
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration, AI and the two shelves', async () => {
|
||||
const { result, rerender } = await loaded();
|
||||
rerender();
|
||||
expect(adminApi.addons).toHaveBeenCalledTimes(1);
|
||||
const ids = (list: { id?: string; key?: string }[]) => list.map((a) => a.id ?? a.key);
|
||||
expect(ids(result.current.tripAddons)).toEqual(['todo', 'documents']);
|
||||
expect(ids(result.current.globalAddons)).toEqual(['journey']);
|
||||
expect(ids(result.current.integrationAddons)).toEqual(['llm_parsing']);
|
||||
// The AI addons leave the integration list for their own tile, in its order.
|
||||
expect(ids(result.current.integrationAddons)).toEqual(['airtrail']);
|
||||
expect(ids(result.current.aiAddons)).toEqual(['llm_parsing', 'mcp']);
|
||||
expect(result.current.providerOptions).toEqual([
|
||||
expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }),
|
||||
]);
|
||||
@@ -149,6 +154,16 @@ describe('useAddonManager', () => {
|
||||
expect(mapSwitch(result.current, 'hiking').enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADDONMGR-010: the piste router switch under Tours loads on, flips and saves on its own (SKI-02)', async () => {
|
||||
const update = vi.spyOn(skiRoutingAdminApi, 'update').mockResolvedValue({ enabled: false });
|
||||
const { result } = await loaded();
|
||||
await waitFor(() => expect(mapSwitch(result.current, 'ski').enabled).toBe(true));
|
||||
await act(() => mapSwitch(result.current, 'ski').toggle());
|
||||
expect(update).toHaveBeenCalledWith(false);
|
||||
expect(mapSwitch(result.current, 'ski').enabled).toBe(false);
|
||||
expect(mapSwitch(result.current, 'cycle').enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => {
|
||||
vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500'));
|
||||
const { result } = await loaded();
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import { Bike, Footprints } from 'lucide-react';
|
||||
import type { AdminFeatureToggleResponse } from '@trek/shared';
|
||||
import { Bike, Footprints, MountainSnow } from 'lucide-react';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
|
||||
import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps';
|
||||
import { wandererAdminApi } from '../../../api/wanderer';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
||||
import { aiAddonsOf, isAiAddon } from './aiAddons';
|
||||
|
||||
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
|
||||
function loadToursFlag(
|
||||
api: { get: () => Promise<AdminFeatureToggleResponse> },
|
||||
set: (enabled: boolean) => void,
|
||||
onError: () => void
|
||||
) {
|
||||
api
|
||||
.get()
|
||||
.then((flag) => set(flag.enabled))
|
||||
.catch(onError);
|
||||
}
|
||||
|
||||
/**
|
||||
* The addon switches behind both admin shells: the addon list loaded on mount, an
|
||||
@@ -24,6 +38,7 @@ export function useAddonManager() {
|
||||
const [toursWanderer, setToursWanderer] = useState(false);
|
||||
const [toursHikingMap, setToursHikingMap] = useState(false);
|
||||
const [toursCycleMap, setToursCycleMap] = useState(false);
|
||||
const [toursSkiRouting, setToursSkiRouting] = useState(false);
|
||||
|
||||
const loadAddons = async () => {
|
||||
try {
|
||||
@@ -36,18 +51,11 @@ export function useAddonManager() {
|
||||
|
||||
const loadOnMount = useEffectEvent(() => {
|
||||
void loadAddons().finally(() => setLoading(false));
|
||||
wandererAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursWanderer(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
hikingMapAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursHikingMap(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
cycleMapAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursCycleMap(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
const unreadable = () => toast.error(t('admin.addons.toast.error'));
|
||||
loadToursFlag(wandererAdminApi, setToursWanderer, unreadable);
|
||||
loadToursFlag(hikingMapAdminApi, setToursHikingMap, unreadable);
|
||||
loadToursFlag(cycleMapAdminApi, setToursCycleMap, unreadable);
|
||||
loadToursFlag(skiRoutingAdminApi, setToursSkiRouting, unreadable);
|
||||
});
|
||||
useEffect(() => {
|
||||
loadOnMount();
|
||||
@@ -94,7 +102,7 @@ export function useAddonManager() {
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
};
|
||||
const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update);
|
||||
/** The maps the Tours planner offers on their operators' terms, one switch each (MAP-01, BIK-06). */
|
||||
/** The maps and the piste router Tours offers on their operators' terms, one switch each (MAP-01, BIK-06, SKI-02). */
|
||||
const mapSwitches = [
|
||||
{
|
||||
key: 'hiking',
|
||||
@@ -112,6 +120,14 @@ export function useAddonManager() {
|
||||
enabled: toursCycleMap,
|
||||
toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update),
|
||||
},
|
||||
{
|
||||
key: 'ski',
|
||||
Icon: MountainSnow,
|
||||
titleKey: 'admin.toursSkiRouting.title',
|
||||
subtitleKey: 'admin.toursSkiRouting.subtitle',
|
||||
enabled: toursSkiRouting,
|
||||
toggle: () => flipToursSwitch(toursSkiRouting, setToursSkiRouting, skiRoutingAdminApi.update),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const asOption = (provider: Addon): ProviderOption => ({
|
||||
@@ -124,7 +140,9 @@ export function useAddonManager() {
|
||||
|
||||
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
|
||||
const globalAddons = addons.filter((a) => a.type === 'global');
|
||||
const integrationAddons = addons.filter((a) => a.type === 'integration');
|
||||
// The AI addons leave the list to sit together on one tile at its head.
|
||||
const integrationAddons = addons.filter((a) => a.type === 'integration' && !isAiAddon(a));
|
||||
const aiAddons = aiAddonsOf(addons);
|
||||
const providerOptions = addons.filter((a) => a.type === 'photo_provider').map(asOption);
|
||||
// No credential form under these, unlike the photo providers: a document connection
|
||||
// belongs to a trip and is entered there. The admin only decides whether a provider
|
||||
@@ -138,6 +156,7 @@ export function useAddonManager() {
|
||||
tripAddons,
|
||||
globalAddons,
|
||||
integrationAddons,
|
||||
aiAddons,
|
||||
providerOptions,
|
||||
documentProviderOptions,
|
||||
toursWanderer,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import type { Addon } from './addonModel';
|
||||
import { usesConnection } from './aiAddons';
|
||||
|
||||
/**
|
||||
* The AI tile behind both admin shells: its three switches, how many are on,
|
||||
* and the settings dialog. The dialog opens by itself when OpenRouter's
|
||||
* sign-in returns here (`?ai=openrouter`), since the connection form that
|
||||
* finishes it lives inside.
|
||||
*/
|
||||
export function useAiAddonTile(aiAddons: Addon[]) {
|
||||
const [params] = useSearchParams();
|
||||
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
|
||||
const canSetUp = usesConnection(aiAddons);
|
||||
return {
|
||||
enabledCount: aiAddons.filter((a) => a.enabled).length,
|
||||
canSetUp,
|
||||
dialogOpen: open && canSetUp,
|
||||
openDialog: () => setOpen(true),
|
||||
closeDialog: () => setOpen(false),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
||||
import { MASKED_SETTING_VALUE } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
adminSettings: vi.fn(),
|
||||
saveAdminSettings: vi.fn(),
|
||||
adminConnection: vi.fn(),
|
||||
saveAdminConnection: vi.fn(),
|
||||
removeAdminConnection: vi.fn(),
|
||||
adminModels: vi.fn(),
|
||||
}));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const { useAiAdminConfig } = await import('./useAiAdminConfig');
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: ['/admin?tab=addons'] },
|
||||
createElement(TranslationProvider, null, children)
|
||||
);
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
||||
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'anthropic',
|
||||
model: 'claude-sonnet',
|
||||
hint: '…a1b2',
|
||||
status: 'ok',
|
||||
host: 'api.anthropic.com',
|
||||
baseUrl: null,
|
||||
},
|
||||
prefill: null,
|
||||
};
|
||||
|
||||
async function renderLoaded() {
|
||||
const view = renderHook(() => useAiAdminConfig(), { wrapper });
|
||||
await waitFor(() => expect(view.result.current.loaded).toBe(true));
|
||||
return view;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
repo.adminConnection.mockResolvedValue(NONE);
|
||||
repo.saveAdminConnection.mockResolvedValue(LIVE);
|
||||
repo.removeAdminConnection.mockResolvedValue(undefined);
|
||||
repo.adminModels.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]);
|
||||
});
|
||||
|
||||
describe('useAiAdminConfig', () => {
|
||||
it('FE-HOOK-AI-ADMIN-001: loads the settings and, without a connection, opens the setup for everyone', async () => {
|
||||
const { result } = await renderLoaded();
|
||||
expect(result.current.policy).toBe('instance_or_user');
|
||||
expect(result.current.policyHintKey).toBe('assistant.admin.policy.instanceOrUserHint');
|
||||
expect(result.current.instancePays).toBe(true);
|
||||
expect(result.current.showPanel).toBe(true);
|
||||
expect(result.current.dailyDraft).toBe('50');
|
||||
expect(result.current.retention).toBe('90');
|
||||
expect(result.current.retentionOptions.map((o) => o.label)).toEqual(['30 days', '90 days', '365 days', 'Forever']);
|
||||
expect(repo.status).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-002: a policy change saves at once, and a refused one is taken back', async () => {
|
||||
const { result } = await renderLoaded();
|
||||
await act(async () => result.current.setPolicy('user'));
|
||||
await waitFor(() => expect(result.current.policy).toBe('user'));
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
|
||||
expect(result.current.instancePays).toBe(false);
|
||||
|
||||
repo.saveAdminSettings.mockRejectedValueOnce(new Error('nope'));
|
||||
await act(async () => result.current.setPolicy('instance'));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.policy).toBe('user');
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-003: the daily limit is kept within 1 to 10000 and saved when the field is left', async () => {
|
||||
const { result } = await renderLoaded();
|
||||
act(() => result.current.setDailyDraft('20000'));
|
||||
await act(async () => result.current.commitDailyLimit());
|
||||
expect(result.current.dailyDraft).toBe('10000');
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, dailyTurns: 10000 });
|
||||
|
||||
vi.clearAllMocks();
|
||||
act(() => result.current.setDailyDraft(''));
|
||||
await act(async () => result.current.commitDailyLimit());
|
||||
expect(result.current.dailyDraft).toBe('10000');
|
||||
expect(repo.saveAdminSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-004: a retention period set elsewhere stays selectable', async () => {
|
||||
repo.adminSettings.mockResolvedValue({ ...SETTINGS, retention: { mode: 'days', days: 120 } });
|
||||
const { result } = await renderLoaded();
|
||||
expect(result.current.retentionOptions.map((o) => o.value)).toEqual(['30', '90', '120', '365', 'forever']);
|
||||
await act(async () => result.current.setRetention('forever'));
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, retention: { mode: 'forever' } });
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-005: another model keeps the stored key by sending the mask', async () => {
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
const { result } = await renderLoaded();
|
||||
expect(result.current.showPanel).toBe(false);
|
||||
await waitFor(() => expect(result.current.instance.models).toHaveLength(1));
|
||||
await act(async () => result.current.instance.setModel('claude-haiku'));
|
||||
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
|
||||
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
|
||||
kind: 'anthropic',
|
||||
apiKey: MASKED_SETTING_VALUE,
|
||||
model: 'claude-haiku',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-006: an own server without a key sends none', async () => {
|
||||
repo.adminConnection.mockResolvedValue({
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'custom',
|
||||
model: 'llama3',
|
||||
hint: null,
|
||||
status: 'ok',
|
||||
host: 'ollama:11434',
|
||||
baseUrl: 'http://ollama:11434/v1',
|
||||
},
|
||||
prefill: null,
|
||||
});
|
||||
const { result } = await renderLoaded();
|
||||
await act(async () => result.current.instance.setModel('qwen3:8b'));
|
||||
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
|
||||
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
|
||||
kind: 'custom',
|
||||
baseUrl: 'http://ollama:11434/v1',
|
||||
model: 'qwen3:8b',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
|
||||
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
|
||||
const { result } = await renderLoaded();
|
||||
act(() => result.current.instance.replace());
|
||||
expect(result.current.showPanel).toBe(true);
|
||||
act(() => result.current.instance.cancelReplace());
|
||||
expect(result.current.showPanel).toBe(false);
|
||||
|
||||
act(() => result.current.instance.askRemove());
|
||||
expect(result.current.instance.confirmingRemove).toBe(true);
|
||||
act(() => result.current.instance.remove());
|
||||
await waitFor(() => expect(result.current.instance.live).toBeNull());
|
||||
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.instance.confirmingRemove).toBe(false);
|
||||
expect(result.current.showPanel).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
MASKED_SETTING_VALUE,
|
||||
type AiAdminConnectionResponse,
|
||||
type AiAdminSettings,
|
||||
type AiInstanceConnectionRequest,
|
||||
type AiPolicy,
|
||||
type AiRetention,
|
||||
} from '@trek/shared';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { providerLabel } from '../../Assistant/providerCatalog';
|
||||
import { connectErrorKey } from '../../Settings/ai/aiConnectModel';
|
||||
import { useOpenrouterReturn } from '../../Settings/ai/useAiConnect';
|
||||
import { modelOptions, useAiModels } from '../../Settings/ai/useAiConnections';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
|
||||
/** Who pays, as the admin reads it ("I pay" rather than "the instance"); the first is the default. */
|
||||
export const POLICY_CHOICES: readonly { value: AiPolicy; labelKey: string; hintKey: string }[] = [
|
||||
{
|
||||
value: 'instance_or_user',
|
||||
labelKey: 'assistant.admin.policy.instanceOrUser',
|
||||
hintKey: 'assistant.admin.policy.instanceOrUserHint',
|
||||
},
|
||||
{ value: 'instance', labelKey: 'assistant.admin.policy.instance', hintKey: 'assistant.admin.policy.instanceHint' },
|
||||
{ value: 'user', labelKey: 'assistant.admin.policy.user', hintKey: 'assistant.admin.policy.userHint' },
|
||||
];
|
||||
|
||||
const RETENTION_DAYS = [30, 90, 365];
|
||||
const FOREVER = 'forever';
|
||||
const MAX_DAILY_TURNS = 10_000;
|
||||
|
||||
/** A retention as the select's value: its days, or `forever`. */
|
||||
function retentionValue(retention: AiRetention): string {
|
||||
return retention.mode === 'forever' ? FOREVER : String(retention.days);
|
||||
}
|
||||
|
||||
function retentionOf(value: string): AiRetention {
|
||||
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
|
||||
}
|
||||
|
||||
/** Policy, daily limit and retention: each change is saved at once and taken back when the server refuses it. */
|
||||
function useAdminSettings(onSaved: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<AiAdminSettings | null>(null);
|
||||
const [dailyDraft, setDailyDraft] = useState('');
|
||||
|
||||
const adopt = (next: AiAdminSettings) => {
|
||||
setSettings(next);
|
||||
setDailyDraft(String(next.dailyTurns));
|
||||
};
|
||||
|
||||
const save = async (patch: Partial<AiAdminSettings>) => {
|
||||
if (!settings) return;
|
||||
const before = settings;
|
||||
setSettings({ ...settings, ...patch });
|
||||
try {
|
||||
adopt(await aiConnectionsRepo.saveAdminSettings({ ...settings, ...patch }));
|
||||
onSaved();
|
||||
} catch {
|
||||
adopt(before);
|
||||
toast.error(t('assistant.error.generic'));
|
||||
}
|
||||
};
|
||||
|
||||
/** The typed limit, kept within what the server takes, saved when the field is left. */
|
||||
const commitDailyLimit = () => {
|
||||
if (!settings) return;
|
||||
const typed = Number.parseInt(dailyDraft, 10);
|
||||
const next = Number.isFinite(typed) ? Math.min(MAX_DAILY_TURNS, Math.max(1, typed)) : settings.dailyTurns;
|
||||
setDailyDraft(String(next));
|
||||
if (next !== settings.dailyTurns) void save({ dailyTurns: next });
|
||||
};
|
||||
|
||||
const current = settings ? retentionValue(settings.retention) : '';
|
||||
// A period set some other way stays selectable instead of reading as blank.
|
||||
const unlisted = current !== '' && current !== FOREVER && !RETENTION_DAYS.includes(Number(current));
|
||||
const days = unlisted ? [...RETENTION_DAYS, Number(current)].sort((a, b) => a - b) : RETENTION_DAYS;
|
||||
|
||||
return {
|
||||
settings,
|
||||
adopt,
|
||||
setPolicy: (policy: AiPolicy) => void save({ policy }),
|
||||
dailyDraft,
|
||||
setDailyDraft,
|
||||
commitDailyLimit,
|
||||
retention: current,
|
||||
retentionOptions: [
|
||||
...days.map((d) => ({ value: String(d), label: t('assistant.admin.retentionDays', { count: d }) })),
|
||||
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
|
||||
],
|
||||
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The connection for everyone: shown, its model switched, replaced or removed. */
|
||||
function useInstanceConnection(onChanged: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [connection, setConnection] = useState<AiAdminConnectionResponse | null>(null);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const [confirmingRemove, setConfirmingRemove] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const live = connection?.connection ?? null;
|
||||
const provider = live ? providerLabel(live.kind, live.host) : '';
|
||||
const models = useAiModels(
|
||||
live?.status === 'ok' ? () => aiConnectionsRepo.adminModels() : null,
|
||||
live ? `${live.kind}@${live.host}` : ''
|
||||
);
|
||||
|
||||
const act = async (action: () => Promise<AiAdminConnectionResponse>, doneKey: string | null) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setConnection(await action());
|
||||
onChanged();
|
||||
if (doneKey) toast.success(t(doneKey));
|
||||
} catch (err) {
|
||||
toast.error(t(connectErrorKey(err), { provider }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
|
||||
const setModel = (model: string) => {
|
||||
if (!live || live.kind === 'chatgpt') return;
|
||||
const request: AiInstanceConnectionRequest =
|
||||
live.kind === 'custom'
|
||||
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
|
||||
: { kind: live.kind, apiKey: MASKED_SETTING_VALUE, model };
|
||||
void act(() => aiConnectionsRepo.saveAdminConnection(request), 'common.saved');
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
setConfirmingRemove(false);
|
||||
void act(async () => {
|
||||
await aiConnectionsRepo.removeAdminConnection();
|
||||
return aiConnectionsRepo.adminConnection();
|
||||
}, null);
|
||||
};
|
||||
|
||||
const onConnected = () => {
|
||||
setReplacing(false);
|
||||
void act(() => aiConnectionsRepo.adminConnection(), null);
|
||||
};
|
||||
|
||||
return {
|
||||
connection,
|
||||
setConnection,
|
||||
live,
|
||||
provider,
|
||||
models: live
|
||||
? modelOptions(models, live.model, {
|
||||
free: t('assistant.model.free'),
|
||||
recommended: t('assistant.model.recommended'),
|
||||
})
|
||||
: [],
|
||||
busy,
|
||||
setModel,
|
||||
replacing,
|
||||
replace: () => setReplacing(true),
|
||||
cancelReplace: () => setReplacing(false),
|
||||
onConnected,
|
||||
confirmingRemove,
|
||||
askRemove: () => setConfirmingRemove(true),
|
||||
cancelRemove: () => setConfirmingRemove(false),
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The AI assistant's admin settings behind both admin shells, on the addon's
|
||||
* own tile: who pays, the connection everyone runs on, the daily limit while
|
||||
* the instance pays, and how long chats are kept. The assistant's status is
|
||||
* reloaded after every change, so the launcher appears or goes at once.
|
||||
*/
|
||||
export function useAiAdminConfig() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const refreshStatus = () => void loadStatus();
|
||||
const form = useAdminSettings(refreshStatus);
|
||||
const instance = useInstanceConnection(refreshStatus);
|
||||
useOpenrouterReturn(instance.onConnected);
|
||||
|
||||
const loadOnMount = useEffectEvent((isCancelled: () => boolean) => {
|
||||
Promise.all([aiConnectionsRepo.adminSettings(), aiConnectionsRepo.adminConnection()]).then(
|
||||
([settings, connection]) => {
|
||||
if (isCancelled()) return;
|
||||
form.adopt(settings);
|
||||
instance.setConnection(connection);
|
||||
},
|
||||
() => {
|
||||
if (!isCancelled()) toast.error(t('assistant.error.generic'));
|
||||
}
|
||||
);
|
||||
// The connect panel reads whether OpenRouter can return here from the status.
|
||||
refreshStatus();
|
||||
});
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadOnMount(() => cancelled);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const policy = form.settings?.policy ?? null;
|
||||
return {
|
||||
...form,
|
||||
instance,
|
||||
loaded: form.settings !== null && instance.connection !== null,
|
||||
policy,
|
||||
policyHintKey: POLICY_CHOICES.find((c) => c.value === policy)?.hintKey ?? null,
|
||||
/** Policy `user` charges nothing to the instance: no connection and no limit to set. */
|
||||
instancePays: policy !== null && policy !== 'user',
|
||||
showPanel: instance.live === null || instance.replacing,
|
||||
};
|
||||
}
|
||||
@@ -5,10 +5,14 @@ import { adminApi } from '../../../api/client';
|
||||
import { type Addon, DEFAULT_OLLAMA_URL, MASKED } from './addonModel';
|
||||
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
||||
|
||||
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-011
|
||||
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-013
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
/** A local connection of parsing's own, so the form is open and lists the server's models. */
|
||||
const OWN_LOCAL = { provider: 'local', model: 'm' };
|
||||
|
||||
function llm(config?: Record<string, unknown>): Addon {
|
||||
return {
|
||||
@@ -45,7 +49,7 @@ describe('useLlmParsingConfig', () => {
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-002: the local provider lists the installed models from the default URL', async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
||||
expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL);
|
||||
expect(result.current.isInstalled('qwen3.5:4b')).toBe(true);
|
||||
@@ -54,7 +58,7 @@ describe('useLlmParsingConfig', () => {
|
||||
|
||||
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
|
||||
useLlmParsingConfig(llm({ provider: 'openai', model: 'gpt', baseUrl: ' http://box:11434/v1 ' }))
|
||||
);
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
await act(() => result.current.loadModels());
|
||||
@@ -69,13 +73,13 @@ describe('useLlmParsingConfig', () => {
|
||||
|
||||
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.modelsErr).toBe('ECONNREFUSED'));
|
||||
expect(result.current.installed).toEqual([]);
|
||||
expect(result.current.loadingModels).toBe(false);
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue('nope');
|
||||
await act(() => result.current.loadModels());
|
||||
expect(result.current.modelsErr).toBe('Could not reach the local LLM server');
|
||||
expect(result.current.modelsErr).toBe('admin.addons.ai.parsing.unreachable');
|
||||
});
|
||||
|
||||
it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => {
|
||||
@@ -83,7 +87,7 @@ describe('useLlmParsingConfig', () => {
|
||||
response: { status: 502, data: { error: 'Local LLM server rejected the API key (401)' } },
|
||||
});
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(refused);
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)'));
|
||||
});
|
||||
|
||||
@@ -94,14 +98,14 @@ describe('useLlmParsingConfig', () => {
|
||||
progress = onProgress;
|
||||
return new Promise<void>((r) => (finish = r));
|
||||
});
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.pull('qwen3.5:4b');
|
||||
});
|
||||
expect(result.current.pulling).toBe('qwen3.5:4b');
|
||||
expect(result.current.pullStatus).toBe('starting…');
|
||||
expect(result.current.pullStatus).toBe('admin.addons.ai.parsing.pulling');
|
||||
act(() => progress({ status: 'downloading', total: 200, completed: 50 }));
|
||||
expect(result.current.pullStatus).toBe('downloading');
|
||||
expect(result.current.pullPct).toBe(25);
|
||||
@@ -109,7 +113,7 @@ describe('useLlmParsingConfig', () => {
|
||||
finish();
|
||||
await pending;
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Model pulled');
|
||||
expect(toast.success).toHaveBeenCalledWith('admin.addons.ai.parsing.pulled');
|
||||
expect(result.current.model).toBe('qwen3.5:4b');
|
||||
expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.pulling).toBeNull();
|
||||
@@ -136,7 +140,7 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(toast.error).toHaveBeenCalledWith('manifest unknown');
|
||||
vi.mocked(adminApi.llmLocalPull).mockRejectedValue('nope');
|
||||
await act(() => result.current.pull('x'));
|
||||
expect(toast.error).toHaveBeenLastCalledWith('Pull failed');
|
||||
expect(toast.error).toHaveBeenLastCalledWith('admin.addons.ai.parsing.pullFailed');
|
||||
expect(result.current.pulling).toBeNull();
|
||||
});
|
||||
|
||||
@@ -148,18 +152,17 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', {
|
||||
config: { provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o/v1', apiKey: MASKED, vision: 'auto' },
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Saved');
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.aiParsing.toast.saved');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
|
||||
act(() => {
|
||||
result.current.setProvider('anthropic');
|
||||
result.current.setVision('off');
|
||||
});
|
||||
await act(async () => result.current.setVision('off'));
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
await act(() => result.current.save());
|
||||
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1]).toEqual({
|
||||
const calls = vi.mocked(adminApi.updateAddon).mock.calls;
|
||||
expect(calls[calls.length - 1]?.[1]).toEqual({
|
||||
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
|
||||
});
|
||||
});
|
||||
@@ -212,7 +215,35 @@ describe('useLlmParsingConfig', () => {
|
||||
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
|
||||
await act(() => result.current.save());
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to save');
|
||||
expect(toast.error).toHaveBeenCalledWith('settings.aiParsing.toast.saveError');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-012: without its own connection the form stays shut and asks the server nothing until opened', async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ vision: 'on' })));
|
||||
expect(result.current.ownConnection).toBe(false);
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
act(() => result.current.startOwn());
|
||||
expect(result.current.formOpen).toBe(true);
|
||||
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
|
||||
// The vision setting alone is saved without a connection.
|
||||
await act(async () => result.current.setVision('off'));
|
||||
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('llm_parsing', { config: { vision: 'off' } });
|
||||
act(() => result.current.cancelOwn());
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-013: going back to the shared connection drops the own one and keeps the vision setting', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'claude-x', apiKey: MASKED, vision: 'on' }))
|
||||
);
|
||||
expect(result.current.ownConnection).toBe(true);
|
||||
expect(result.current.savedModel).toBe('claude-x');
|
||||
await act(() => result.current.switchToShared());
|
||||
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', { config: { vision: 'on' } });
|
||||
expect(result.current.ownConnection).toBe(false);
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
expect(result.current.model).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,33 +2,118 @@ import { type LlmVision, asLlmVision } from '@trek/shared';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config behind both admin shells. When set, it applies to the
|
||||
* whole instance and overrides per-user config (see server llmConfig.ts). The API key is
|
||||
* masked on read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider it also lists the installed Ollama models and can pull the recommended ones.
|
||||
*/
|
||||
export function useLlmParsingConfig(addon: Addon) {
|
||||
const toast = useToast();
|
||||
const cfg = addon.config ?? {};
|
||||
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
|
||||
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
|
||||
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
|
||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
|
||||
const [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision));
|
||||
const [saving, setSaving] = useState(false);
|
||||
/** What AI parsing keeps in its addon config: a connection of its own (or none) and the vision setting. */
|
||||
interface SavedParsing {
|
||||
provider: string;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
vision: LlmVision;
|
||||
}
|
||||
|
||||
// Local-provider model management.
|
||||
function savedOf(cfg: Record<string, unknown>): SavedParsing {
|
||||
return {
|
||||
provider: typeof cfg.provider === 'string' ? cfg.provider : '',
|
||||
model: typeof cfg.model === 'string' ? cfg.model : '',
|
||||
baseUrl: typeof cfg.baseUrl === 'string' ? cfg.baseUrl : '',
|
||||
apiKey: typeof cfg.apiKey === 'string' ? cfg.apiKey : '',
|
||||
vision: asLlmVision(cfg.vision),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The models a local (Ollama) server has, and pulling the recommended ones.
|
||||
* The list loads while the form shows the local provider: a provider change or
|
||||
* opening the form loads it, the address field's own blur reloads it.
|
||||
*/
|
||||
function useLocalModels(baseUrl: string, isLocal: boolean, formOpen: boolean, onPulled: (id: string) => void) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [installed, setInstalled] = useState<string[]>([]);
|
||||
const [modelsErr, setModelsErr] = useState('');
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [pulling, setPulling] = useState<string | null>(null);
|
||||
const [pullPct, setPullPct] = useState(0);
|
||||
const [pullStatus, setPullStatus] = useState('');
|
||||
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
|
||||
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
|
||||
|
||||
const loadModels = async () => {
|
||||
if (!isLocal) return;
|
||||
setLoadingModels(true);
|
||||
setModelsErr('');
|
||||
try {
|
||||
const res = await adminApi.llmLocalModels(effectiveUrl);
|
||||
setInstalled(res.models.map((m) => m.name));
|
||||
} catch (e: unknown) {
|
||||
// The server says why (unreachable, key refused); axios' own status line does not.
|
||||
setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable')));
|
||||
setInstalled([]);
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadModelsForProvider = useEffectEvent(() => {
|
||||
if (formOpen && isLocal) void loadModels();
|
||||
});
|
||||
useEffect(() => {
|
||||
loadModelsForProvider();
|
||||
}, [isLocal, formOpen]);
|
||||
|
||||
const pull = async (id: string) => {
|
||||
if (pulling) return;
|
||||
setPulling(id);
|
||||
setPullPct(0);
|
||||
setPullStatus(t('admin.addons.ai.parsing.pulling'));
|
||||
try {
|
||||
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
|
||||
if (p.error) throw new Error(p.error);
|
||||
if (p.status) setPullStatus(p.status);
|
||||
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
|
||||
});
|
||||
toast.success(t('admin.addons.ai.parsing.pulled'));
|
||||
onPulled(id);
|
||||
await loadModels();
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
|
||||
} finally {
|
||||
setPulling(null);
|
||||
setPullPct(0);
|
||||
setPullStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
return { installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull };
|
||||
}
|
||||
|
||||
/**
|
||||
* AI parsing's admin settings behind both admin shells. Parsing reads with the
|
||||
* AI connection the assistant runs on unless the instance keeps one of its own
|
||||
* here; an install set up before the two shared one keeps its own until the
|
||||
* admin switches it over. The API key is masked on read and an unchanged mask
|
||||
* keeps the stored key. For a local server it lists the installed Ollama models
|
||||
* and pulls the recommended ones.
|
||||
*/
|
||||
export function useLlmParsingConfig(addon: Addon) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [saved, setSaved] = useState<SavedParsing>(() => savedOf(addon.config ?? {}));
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [provider, setProviderState] = useState<string>(saved.provider || 'local');
|
||||
const [model, setModel] = useState<string>(saved.model);
|
||||
const [baseUrl, setBaseUrl] = useState<string>(saved.baseUrl);
|
||||
const [apiKey, setApiKey] = useState<string>(saved.apiKey);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
/** A connection of its own, which wins over the shared one. */
|
||||
const ownConnection = !!(saved.provider && saved.model);
|
||||
const formOpen = ownConnection || editing;
|
||||
|
||||
/**
|
||||
* A different provider never inherits the last one's endpoint: an Ollama address
|
||||
@@ -37,83 +122,58 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
* keep an address the admin typed for it (#2655); empty means the provider default.
|
||||
*/
|
||||
const setProvider = (next: string) => {
|
||||
if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : '');
|
||||
if (next !== provider) setBaseUrl(next === saved.provider ? saved.baseUrl : '');
|
||||
setProviderState(next);
|
||||
};
|
||||
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
|
||||
const local = useLocalModels(baseUrl, provider === 'local', formOpen, setModel);
|
||||
|
||||
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
|
||||
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
|
||||
|
||||
const loadModels = async () => {
|
||||
if (provider !== 'local') return;
|
||||
setLoadingModels(true);
|
||||
setModelsErr('');
|
||||
try {
|
||||
const res = await adminApi.llmLocalModels(effectiveUrl);
|
||||
setInstalled(res.models.map((m) => m.name));
|
||||
} catch (e: unknown) {
|
||||
// The server says why (unreachable, key refused); axios' own status line does not.
|
||||
setModelsErr(getApiErrorMessage(e, 'Could not reach the local LLM server'));
|
||||
setInstalled([]);
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load installed models when the local provider is active. Only a provider change
|
||||
// triggers it; the base URL is read as it stands then (its own blur reloads).
|
||||
const loadModelsForProvider = useEffectEvent(() => {
|
||||
if (provider === 'local') void loadModels();
|
||||
});
|
||||
useEffect(() => {
|
||||
loadModelsForProvider();
|
||||
}, [provider]);
|
||||
|
||||
const pull = async (id: string) => {
|
||||
if (pulling) return;
|
||||
setPulling(id);
|
||||
setPullPct(0);
|
||||
setPullStatus('starting…');
|
||||
try {
|
||||
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
|
||||
if (p.error) throw new Error(p.error);
|
||||
if (p.status) setPullStatus(p.status);
|
||||
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
|
||||
});
|
||||
toast.success('Model pulled');
|
||||
setModel(id);
|
||||
await loadModels();
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Pull failed');
|
||||
} finally {
|
||||
setPulling(null);
|
||||
setPullPct(0);
|
||||
setPullStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
/** Writes the config; the stored key stays as long as the mask is sent in its place. */
|
||||
const write = async (next: SavedParsing, config: Record<string, unknown>): Promise<boolean> => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Send the masked sentinel unchanged so the server keeps the stored key.
|
||||
await adminApi.updateAddon(addon.id, {
|
||||
config: {
|
||||
provider,
|
||||
model: model.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiKey,
|
||||
vision,
|
||||
},
|
||||
});
|
||||
toast.success('Saved');
|
||||
await adminApi.updateAddon(addon.id, { config });
|
||||
setSaved(next);
|
||||
toast.success(t('settings.aiParsing.toast.saved'));
|
||||
return true;
|
||||
} catch {
|
||||
toast.error('Failed to save');
|
||||
toast.error(t('settings.aiParsing.toast.saveError'));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ownConfig = (s: SavedParsing) => ({
|
||||
provider: s.provider,
|
||||
model: s.model,
|
||||
baseUrl: s.baseUrl,
|
||||
apiKey: s.apiKey,
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
const next: SavedParsing = { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, vision: saved.vision };
|
||||
if (await write(next, { ...ownConfig(next), vision: next.vision })) setEditing(false);
|
||||
};
|
||||
|
||||
/** Back on the shared connection: the own one and its key are dropped, the vision setting stays. */
|
||||
const switchToShared = async () => {
|
||||
const next: SavedParsing = { provider: '', model: '', baseUrl: '', apiKey: '', vision: saved.vision };
|
||||
if (await write(next, { vision: next.vision })) {
|
||||
setEditing(false);
|
||||
setProviderState('local');
|
||||
setModel('');
|
||||
setBaseUrl('');
|
||||
setApiKey('');
|
||||
}
|
||||
};
|
||||
|
||||
/** Saved at once, with the connection as it was saved: a draft in the form is not sent along. */
|
||||
const setVision = (value: LlmVision) => {
|
||||
const next = { ...saved, vision: value };
|
||||
void write(next, ownConnection ? { ...ownConfig(saved), vision: value } : { vision: value });
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
setProvider,
|
||||
@@ -124,18 +184,17 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
baseUrlPlaceholder,
|
||||
apiKey,
|
||||
setApiKey,
|
||||
vision,
|
||||
vision: saved.vision,
|
||||
setVision,
|
||||
saving,
|
||||
installed,
|
||||
modelsErr,
|
||||
loadingModels,
|
||||
pulling,
|
||||
pullPct,
|
||||
pullStatus,
|
||||
isInstalled,
|
||||
loadModels,
|
||||
pull,
|
||||
ownConnection,
|
||||
savedProvider: saved.provider,
|
||||
savedModel: saved.model,
|
||||
formOpen,
|
||||
startOwn: () => setEditing(true),
|
||||
cancelOwn: () => setEditing(false),
|
||||
switchToShared,
|
||||
...local,
|
||||
save,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* FE-AI-ASK-001 to FE-AI-ASK-003
|
||||
*
|
||||
* "Ask" in the place inspector: there only while the assistant can run, only
|
||||
* in the planner of the place's trip, and it opens the chat about the place.
|
||||
*/
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildPlace } from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import AssistantAskButton from './AssistantAskButton';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const place = buildPlace({ id: 42, trip_id: 5, name: 'Kinkaku-ji' });
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/trips/:id" element={<AssistantAskButton place={place} />} />
|
||||
<Route path="*" element={<AssistantAskButton place={place} />} />
|
||||
</Routes>,
|
||||
{ initialEntries: [path] }
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => useAssistantStore.setState(INITIAL, true));
|
||||
|
||||
describe('AssistantAskButton', () => {
|
||||
it('FE-AI-ASK-001: shows nothing until the assistant has a connection to use', () => {
|
||||
renderAt('/trips/5');
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-ASK-002: opens the assistant with the place as its context', () => {
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
|
||||
renderAt('/trips/5');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ask: Kinkaku-ji' }));
|
||||
|
||||
const state = useAssistantStore.getState();
|
||||
expect(state.window).toEqual({ open: true, minimized: false });
|
||||
expect(chatOf(state, 5).focus).toEqual({ placeIds: [42] });
|
||||
});
|
||||
|
||||
it('FE-AI-ASK-003: is not offered outside the planner of the trip the place belongs to', () => {
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
|
||||
const { unmount } = renderAt('/trips/6');
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderAt('/collections/3');
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useMatch } from 'react-router';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { Place } from '../../types';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import { useAssistantAsk } from './useAssistantAsk';
|
||||
|
||||
/**
|
||||
* The place inspector's "Ask": a round button in its head, beside close, that
|
||||
* opens the assistant about this place. Only in the planner of the place's own
|
||||
* trip; a saved place in a collection has no trip to ask about.
|
||||
*/
|
||||
export default function AssistantAskButton({ place }: { place: Place }) {
|
||||
const { t } = useTranslation();
|
||||
const inTrip = useMatch('/trips/:id');
|
||||
const tripId = inTrip && Number(inTrip.params.id) === place.trip_id ? place.trip_id : null;
|
||||
const { available, askAbout } = useAssistantAsk(tripId);
|
||||
if (!available) return null;
|
||||
|
||||
const label = t('assistant.context.ask');
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => askAbout(place.id)}
|
||||
aria-label={`${label}: ${place.name}`}
|
||||
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content"
|
||||
>
|
||||
<Sparkles size={15} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* FE-AI-CARD-001 to FE-AI-CARD-013
|
||||
*
|
||||
* The change card: what it asks before anything is written, which decisions
|
||||
* one press sends, and what the receipt offers afterwards (the map, undo
|
||||
* while its window is open, and what a failed undo says). Then what ties it
|
||||
* to the map: a row lights its pin and the pin its row, the days it touches
|
||||
* read as a band, the phone hops to the map, and once applied the block can
|
||||
* be compared before and after and taken back as a whole.
|
||||
*/
|
||||
import type { AssistantApplied, AssistantChangeEffect, AssistantChangePreview } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantChangeCard from './AssistantChangeCard';
|
||||
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||
import type { ChangeItem } from './assistantParts';
|
||||
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
const { addToast } = vi.hoisted(() => ({ addToast: vi.fn() }));
|
||||
// The toasts a component raises, read as (message, type) the way the toast host takes them.
|
||||
vi.mock('../shared/Toast', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../shared/Toast')>()),
|
||||
useToast: () => ({
|
||||
success: (message: string) => addToast(message, 'success', undefined),
|
||||
error: (message: string) => addToast(message, 'error', undefined),
|
||||
warning: (message: string) => addToast(message, 'warning', undefined),
|
||||
info: (message: string) => addToast(message, 'info', undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
const TRIP = 4;
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const TRIP_INITIAL = useTripStore.getState();
|
||||
|
||||
const PANTHEON: AssistantChangeEffect = {
|
||||
kind: 'add',
|
||||
dayId: 3,
|
||||
fromDayId: null,
|
||||
placeId: null,
|
||||
assignmentId: null,
|
||||
place: { name: 'Pantheon', lat: 48.02, lng: 2.0, categoryId: null },
|
||||
};
|
||||
|
||||
function preview(toolCallId: string, subject: string): AssistantChangePreview {
|
||||
return {
|
||||
toolCallId,
|
||||
verbKey: 'addPlace',
|
||||
subject,
|
||||
rows: [
|
||||
{ op: 'add', labelKey: 'address', before: null, after: 'Kyoto' },
|
||||
{ op: 'change', labelKey: 'time', before: '09:00', after: '10:30' },
|
||||
],
|
||||
dayId: 3,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
};
|
||||
}
|
||||
|
||||
const pending = (id: string, subject: string): ChangeItem => ({
|
||||
toolCallId: id,
|
||||
preview: preview(id, subject),
|
||||
applied: null,
|
||||
state: 'pending',
|
||||
});
|
||||
|
||||
const onTheMap = (id: string, subject: string, effect = PANTHEON): ChangeItem => ({
|
||||
...pending(id, subject),
|
||||
preview: { ...preview(id, subject), effect },
|
||||
});
|
||||
|
||||
function receipt(id: string, undoUntil: string | null): ChangeItem {
|
||||
const applied: AssistantApplied = { toolCallId: id, entity: 'place', ids: [9], dayId: 3, undoUntil };
|
||||
return { toolCallId: id, preview: preview(id, 'Kinkaku-ji'), applied, state: 'applied' };
|
||||
}
|
||||
|
||||
function renderCard(items: ChangeItem[], view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={value}>
|
||||
<AssistantChangeCard items={items} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function patchChat(next: Partial<ReturnType<typeof chatOf>>) {
|
||||
useAssistantStore.setState((s) => ({ byTrip: { ...s.byTrip, [TRIP]: { ...chatOf(s, TRIP), ...next } } }));
|
||||
}
|
||||
|
||||
/** The planner holding this trip, with one stop on day 3. */
|
||||
function plannerHere(tripId = TRIP) {
|
||||
const louvre = buildPlace({ id: 10, name: 'Louvre', lat: 48.0, lng: 2.0 });
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: tripId }),
|
||||
days: [buildDay({ id: 3, title: 'Rome' })],
|
||||
assignments: { 3: [buildAssignment({ id: 100, day_id: 3, place: louvre, assignment_time: '09:00' })] },
|
||||
places: [louvre],
|
||||
});
|
||||
}
|
||||
|
||||
const future = () => new Date(Date.now() + 10 * 60_000).toISOString();
|
||||
|
||||
const decide = vi.fn<(tripId: number, decisions: { toolCallId: string; approved: boolean }[]) => Promise<void>>();
|
||||
const undo = vi.fn<(tripId: number, toolCallId: string) => Promise<'undone' | 'stale' | 'failed'>>();
|
||||
const undoBlock =
|
||||
vi.fn<(tripId: number, ids: readonly string[]) => Promise<{ done: number; stale: number; failed: number }>>();
|
||||
const setCompare = vi.fn<(tripId: number, mode: 'before' | 'after') => void>();
|
||||
const requestCamera = vi.fn();
|
||||
const setMinimized = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
decide.mockResolvedValue(undefined);
|
||||
useAssistantStore.setState({ decide, undo, undoBlock, setCompare, requestCamera, setMinimized });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
useTripStore.setState(TRIP_INITIAL, true);
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('AssistantChangeCard: deciding', () => {
|
||||
it('FE-AI-CARD-001: shows what changes in plain words, field by field, before anything is written', () => {
|
||||
renderCard([pending('c1', 'Kinkaku-ji')]);
|
||||
expect(screen.getByText('Kinkaku-ji')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add place')).toBeInTheDocument();
|
||||
// The faint line says when, old to new; the address is for the planner, not the card.
|
||||
expect(screen.getByText('09:00')).toBeInTheDocument();
|
||||
expect(screen.getByText('10:30')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Kyoto')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Include this change' })).toBeNull();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('1 change is waiting for you');
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-002: one change applies or discards with one press', () => {
|
||||
renderCard([pending('c1', 'Kinkaku-ji')]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(decide).toHaveBeenLastCalledWith(TRIP, [{ toolCallId: 'c1', approved: true }]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
|
||||
expect(decide).toHaveBeenLastCalledWith(TRIP, [{ toolCallId: 'c1', approved: false }]);
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-003: several changes each get a switch, and the button applies the ones left on in one request', () => {
|
||||
renderCard([pending('c1', 'Kinkaku-ji'), pending('c2', 'Ryoan-ji'), pending('c3', 'Nanzen-ji')]);
|
||||
expect(screen.getByRole('button', { name: 'Apply 3' })).toBeEnabled();
|
||||
|
||||
const [, second] = screen.getAllByRole('button', { name: 'Include this change' });
|
||||
fireEvent.click(second as HTMLElement);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply 2' }));
|
||||
|
||||
expect(decide).toHaveBeenCalledTimes(1);
|
||||
expect(decide).toHaveBeenCalledWith(TRIP, [
|
||||
{ toolCallId: 'c1', approved: true },
|
||||
{ toolCallId: 'c2', approved: false },
|
||||
{ toolCallId: 'c3', approved: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-004: nothing can be decided while the assistant is still answering', () => {
|
||||
patchChat({ isRunning: true });
|
||||
renderCard([pending('c1', 'Kinkaku-ji')]);
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Discard' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantChangeCard: the receipt', () => {
|
||||
it('FE-AI-CARD-005: an applied change shows on the map and can be undone until its window closes', () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date('2026-10-21T08:00:00.000Z'));
|
||||
const onShowOnMap = vi.fn();
|
||||
renderCard([receipt('c1', '2026-10-21T08:15:00.000Z')], { onShowOnMap });
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Applied' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
|
||||
expect(onShowOnMap).toHaveBeenCalledWith(expect.objectContaining({ toolCallId: 'c1', dayId: 3 }));
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(16 * 60_000);
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-006: an undone change says so, a stale one explains why it stays', async () => {
|
||||
// The store marks what it took back; the card reads it from there.
|
||||
undo.mockImplementationOnce(async (_tripId, toolCallId) => {
|
||||
patchChat({ undone: { [toolCallId]: true } });
|
||||
return 'undone';
|
||||
});
|
||||
const first = renderCard([receipt('c1', future())]);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
});
|
||||
expect(undo).toHaveBeenCalledWith(TRIP, 'c1');
|
||||
expect(screen.getByRole('img', { name: 'Undone' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
undo.mockResolvedValueOnce('stale');
|
||||
renderCard([receipt('c2', future())]);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith('Changed in the meantime. Please adjust it by hand.', 'warning', undefined);
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-007: no map button where nothing can show it, and none of the receipt actions after a discard', () => {
|
||||
renderCard([receipt('c1', null), { ...pending('c2', 'Ryoan-ji'), state: 'discarded' }]);
|
||||
expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
|
||||
expect(screen.getByRole('img', { name: 'Discarded' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantChangeCard: on the map', () => {
|
||||
it('FE-AI-CARD-008: resting on a row lights its pin, and a lit pin lights its row', () => {
|
||||
renderCard([onTheMap('c1', 'Pantheon'), pending('c2', 'Ryoan-ji')]);
|
||||
fireEvent.mouseEnter(screen.getByText('Pantheon'));
|
||||
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'c1', source: 'card' });
|
||||
fireEvent.mouseLeave(screen.getByText('Pantheon'));
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
|
||||
|
||||
// A change the plan does not show has no pin to light.
|
||||
fireEvent.mouseEnter(screen.getByText('Ryoan-ji'));
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
|
||||
|
||||
const row = screen.getByText('Pantheon').closest('[class*="rounded-[12px]"]');
|
||||
expect(row).not.toBeNull();
|
||||
expect(row).not.toHaveClass('bg-surface-hover');
|
||||
act(() => useAssistantStore.setState({ mapLink: { tripId: TRIP, hoveredId: 'c1', source: 'map' } }));
|
||||
expect(row).toHaveClass('bg-surface-hover');
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-009: the head says whether the days still fit, without drawing the days again', () => {
|
||||
plannerHere();
|
||||
renderCard([onTheMap('c1', 'Pantheon')]);
|
||||
expect(screen.getByText(/Fits the time|to check/)).toBeInTheDocument();
|
||||
// The day list of the planner shows the suggestion; the card keeps to one line per change.
|
||||
expect(screen.getAllByText('Pantheon')).toHaveLength(1);
|
||||
expect(screen.queryByRole('button', { name: 'Show schedule' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-010: no check while the planner holds another trip, or once nothing waits', () => {
|
||||
plannerHere(TRIP + 1);
|
||||
const first = renderCard([onTheMap('c1', 'Pantheon')]);
|
||||
expect(screen.queryByText(/Fits the time|to check/)).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
plannerHere();
|
||||
renderCard([{ ...onTheMap('c1', 'Pantheon'), state: 'applied' }]);
|
||||
expect(screen.queryByText(/Fits the time|to check/)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-011: on the phone the card hops to the map and frames what it would change', () => {
|
||||
plannerHere();
|
||||
const onShowOnMap = vi.fn();
|
||||
const first = renderCard([onTheMap('c1', 'Pantheon')], { onShowOnMap });
|
||||
expect(screen.queryByRole('button', { name: 'View on map' })).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
renderCard([onTheMap('c1', 'Pantheon')], { variant: 'fullscreen', onShowOnMap });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View on map' }));
|
||||
expect(requestCamera).toHaveBeenCalledWith(
|
||||
TRIP,
|
||||
[
|
||||
[48.02, 2.0],
|
||||
[48.0, 2.0],
|
||||
],
|
||||
'fit',
|
||||
'user'
|
||||
);
|
||||
expect(setMinimized).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantChangeCard: the applied block', () => {
|
||||
const compare = (blockKey: string, mode: 'before' | 'after' = 'after') =>
|
||||
patchChat({ compare: { blockKey, mode, snapshot: { 3: [] }, newPlaceCalls: [], addedPlaceIds: [] } });
|
||||
|
||||
it('FE-AI-CARD-012: before and after are there only for the block this session can compare, beside a map', () => {
|
||||
compare('c1');
|
||||
const onShowOnMap = vi.fn();
|
||||
const first = renderCard([receipt('c1', null), receipt('c2', null)], { onShowOnMap });
|
||||
expect(screen.getByRole('button', { name: 'After' })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Before' }));
|
||||
expect(setCompare).toHaveBeenCalledWith(TRIP, 'before');
|
||||
first.unmount();
|
||||
|
||||
const other = renderCard([receipt('c7', null), receipt('c8', null)], { onShowOnMap });
|
||||
expect(screen.queryByRole('button', { name: 'Before' })).toBeNull();
|
||||
other.unmount();
|
||||
|
||||
renderCard([receipt('c1', null), receipt('c2', null)]);
|
||||
expect(screen.queryByRole('button', { name: 'Before' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-013: undo all takes back what still can be, and says how it went', async () => {
|
||||
const ids = ['c1', 'c2', 'c3'];
|
||||
const items = () => [receipt('c1', future()), receipt('c2', future()), receipt('c3', null)];
|
||||
const press = async () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo all' }));
|
||||
});
|
||||
};
|
||||
|
||||
undoBlock.mockResolvedValueOnce({ done: 2, stale: 0, failed: 0 });
|
||||
const first = renderCard(items());
|
||||
await press();
|
||||
expect(undoBlock).toHaveBeenCalledWith(TRIP, ids.slice(0, 2));
|
||||
expect(addToast).toHaveBeenLastCalledWith('2 changes undone', 'success', undefined);
|
||||
|
||||
undoBlock.mockResolvedValueOnce({ done: 1, stale: 1, failed: 0 });
|
||||
await press();
|
||||
expect(addToast).toHaveBeenLastCalledWith('1 undone, 1 changed since', 'warning', undefined);
|
||||
|
||||
undoBlock.mockResolvedValueOnce({ done: 0, stale: 0, failed: 2 });
|
||||
await press();
|
||||
expect(addToast).toHaveBeenLastCalledWith('2 changes could not be undone', 'error', undefined);
|
||||
first.unmount();
|
||||
|
||||
// One left to take back is the single undo button's job.
|
||||
patchChat({ undone: { c2: true } });
|
||||
renderCard(items());
|
||||
expect(screen.queryByRole('button', { name: 'Undo all' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantCompareSwitch', () => {
|
||||
it('follows whichever block is compared when it names none', () => {
|
||||
patchChat({ compare: { blockKey: 'c5', mode: 'before', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] } });
|
||||
render(<AssistantCompareSwitch tripId={TRIP} />);
|
||||
expect(screen.getByRole('button', { name: 'Before' })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'After' }));
|
||||
expect(setCompare).toHaveBeenCalledWith(TRIP, 'after');
|
||||
});
|
||||
|
||||
it("gives each side a thumb's room when tall", () => {
|
||||
patchChat({ compare: { blockKey: 'c5', mode: 'after', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] } });
|
||||
render(<AssistantCompareSwitch tripId={TRIP} tall />);
|
||||
expect(screen.getByRole('button', { name: 'Before' }).className).toContain('h-9');
|
||||
expect(screen.getByRole('button', { name: 'After' }).className).toContain('min-w-[44px]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,541 @@
|
||||
import type { AssistantApplied, AssistantChangePreview, AssistantChangeVerbKey } from '@trek/shared';
|
||||
import {
|
||||
CalendarArrowUp,
|
||||
CalendarMinus,
|
||||
CalendarPlus,
|
||||
Check,
|
||||
CircleSlash,
|
||||
ListTodo,
|
||||
LoaderCircle,
|
||||
Luggage,
|
||||
MapPin,
|
||||
MapPinPlus,
|
||||
NotebookPen,
|
||||
PencilLine,
|
||||
Plus,
|
||||
StickyNote,
|
||||
Ticket,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Undo2,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { blockKeyOf } from '../../store/assistantMapModel';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantChangeFields from './AssistantChangeFields';
|
||||
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||
import AssistantFitCheck from './AssistantFitCheck';
|
||||
import AssistantWeekOverview from './AssistantWeekOverview';
|
||||
import { type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { changePoints, type DayChange } from './dayAfterModel';
|
||||
import { useChangeHover } from './useChangeHover';
|
||||
import { useWeekDays } from './useWeekDays';
|
||||
|
||||
/**
|
||||
* The changes the assistant proposes in one go, as one card in plain words:
|
||||
* what happens to which place, note or item, field by field. Nothing is
|
||||
* written before the user applies it. Each change keeps its own decision, so
|
||||
* when the card holds several, each has a switch and the button applies the
|
||||
* ones left on. Once decided, the card turns into a receipt per change, with
|
||||
* a way to the change on the map and, for a while, undo.
|
||||
*
|
||||
* Every change wears its kind: a tinted tile with the icon of what it touches
|
||||
* and a badge naming the verb, in the tone of what happens (green adds, blue
|
||||
* changes, amber moves, red takes away). Its fields sit as badges holding a
|
||||
* label badge, so a row of them reads at a glance.
|
||||
*
|
||||
* While it waits, the card shows the days it touches as they would read
|
||||
* afterwards, and each row lights its pin on the map (and the pin its row).
|
||||
* Once applied, the head offers the plan before and after the block on the
|
||||
* map, and taking the whole block back at once.
|
||||
*/
|
||||
|
||||
type Tone = 'success' | 'info' | 'warning' | 'danger';
|
||||
|
||||
const TILE: Record<Tone, string> = {
|
||||
success: 'bg-success-soft text-success',
|
||||
info: 'bg-info-soft text-info',
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
danger: 'bg-danger-soft text-danger',
|
||||
};
|
||||
|
||||
/** What each kind of change looks like. A table, so a new verb fails to compile until it has a look. */
|
||||
const VERB_LOOK: Record<AssistantChangeVerbKey, { Icon: typeof Check; tone: Tone }> = {
|
||||
addPlace: { Icon: MapPinPlus, tone: 'success' },
|
||||
addPlaceToDay: { Icon: CalendarPlus, tone: 'success' },
|
||||
updatePlace: { Icon: PencilLine, tone: 'info' },
|
||||
assignPlace: { Icon: CalendarPlus, tone: 'success' },
|
||||
moveAssignment: { Icon: CalendarArrowUp, tone: 'warning' },
|
||||
removeFromDay: { Icon: CalendarMinus, tone: 'danger' },
|
||||
addDayNote: { Icon: StickyNote, tone: 'success' },
|
||||
updateDayNote: { Icon: StickyNote, tone: 'info' },
|
||||
addPackingItem: { Icon: Luggage, tone: 'success' },
|
||||
updatePackingItem: { Icon: Luggage, tone: 'info' },
|
||||
addTodo: { Icon: ListTodo, tone: 'success' },
|
||||
addBudgetItem: { Icon: Wallet, tone: 'success' },
|
||||
updateBudgetItem: { Icon: Wallet, tone: 'info' },
|
||||
addReservation: { Icon: Ticket, tone: 'success' },
|
||||
importBooking: { Icon: Ticket, tone: 'success' },
|
||||
rememberNote: { Icon: NotebookPen, tone: 'info' },
|
||||
genericCreate: { Icon: Plus, tone: 'success' },
|
||||
genericUpdate: { Icon: PencilLine, tone: 'info' },
|
||||
genericRemove: { Icon: Trash2, tone: 'danger' },
|
||||
};
|
||||
|
||||
/** How a decided change reads: one quiet mark, its words in the tooltip. */
|
||||
const RECEIPT: Record<Exclude<ChangeState, 'pending'>, { labelKey: string; Icon: typeof Check; tone: string }> = {
|
||||
applying: { labelKey: 'assistant.change.applying', Icon: LoaderCircle, tone: 'text-content-muted' },
|
||||
applied: { labelKey: 'assistant.change.applied', Icon: Check, tone: 'text-success' },
|
||||
failed: { labelKey: 'assistant.change.failed', Icon: TriangleAlert, tone: 'text-danger' },
|
||||
discarded: { labelKey: 'assistant.change.discarded', Icon: CircleSlash, tone: 'text-content-faint' },
|
||||
expired: { labelKey: 'assistant.change.expired', Icon: CircleSlash, tone: 'text-content-faint' },
|
||||
};
|
||||
|
||||
const ICON_BUTTON =
|
||||
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-50';
|
||||
|
||||
function KindTile({
|
||||
verbKey,
|
||||
size = 'md',
|
||||
dimmed = false,
|
||||
}: {
|
||||
verbKey: AssistantChangeVerbKey | null;
|
||||
size?: 'sm' | 'md';
|
||||
dimmed?: boolean;
|
||||
}) {
|
||||
const box = size === 'sm' ? 'h-7 w-7 rounded-md' : 'h-8 w-8 rounded-lg';
|
||||
if (!verbKey) return <span className={`trek-skeleton flex-shrink-0 ${box}`} aria-hidden="true" />;
|
||||
const { Icon, tone } = VERB_LOOK[verbKey];
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`flex flex-shrink-0 items-center justify-center ${box} ${dimmed ? 'bg-surface-secondary text-content-faint' : TILE[tone]}`}
|
||||
>
|
||||
<Icon size={size === 'sm' ? 14 : 16} strokeWidth={2.1} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The key of a tool's own action name, for the tools TREK names one for. */
|
||||
const toolKey = (tool: string) => `assistant.tool.${tool}`;
|
||||
|
||||
/** What the change does: the tool's own action where TREK names it, else add, change or remove. */
|
||||
function verbLabel(preview: AssistantChangePreview, t: (key: string) => string): string {
|
||||
const own = preview.tool ? toolKey(preview.tool) : null;
|
||||
const named = own ? t(own) : null;
|
||||
return named && named !== own ? named : t(`assistant.change.verb.${preview.verbKey}`);
|
||||
}
|
||||
|
||||
function ChangeHead({ preview, struck = false }: { preview: AssistantChangePreview | null; struck?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!preview) return <span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />;
|
||||
return (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<bdi className={`truncate text-body font-semibold leading-tight text-content ${struck ? 'line-through' : ''}`}>
|
||||
{preview.subject}
|
||||
</bdi>
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
|
||||
<span className="truncate">{verbLabel(preview, t)}</span>
|
||||
{preview.domain && (
|
||||
// Where in TREK a change outside the trip plan lands: the vacation planner, the journal.
|
||||
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
|
||||
{t(`assistant.domain.${preview.domain}`)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The time now, ticking once more when the next undo window closes. */
|
||||
function useUndoClock(items: ChangeItem[]): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const next = Math.min(...items.map((i) => undoLeftMs(i.applied, now)).filter((ms) => ms > 0));
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(next)) return;
|
||||
const timer = window.setTimeout(() => setNow(Date.now()), next + 50);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [next]);
|
||||
return now;
|
||||
}
|
||||
|
||||
/** Whether an applied change can still be taken back now. */
|
||||
function undoableNow(item: ChangeItem, undone: Readonly<Record<string, true>>, now: number): boolean {
|
||||
return (
|
||||
item.state === 'applied' &&
|
||||
!undone[item.toolCallId] &&
|
||||
!!item.preview?.undoable &&
|
||||
undoLeftMs(item.applied, now) > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** The waiting changes the user left on that touch the day plan. */
|
||||
function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): DayChange[] {
|
||||
return items.flatMap((i) =>
|
||||
i.state === 'pending' && i.preview?.effect && !excluded.has(i.toolCallId)
|
||||
? [{ toolCallId: i.toolCallId, effect: i.preview.effect, subject: i.preview.subject }]
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
/** What an applied change can be shown as on the map; a kept note, a todo or an expense has no place there. */
|
||||
const ON_MAP: ReadonlySet<AssistantApplied['entity']> = new Set(['place', 'assignment', 'day_note', 'reservation']);
|
||||
|
||||
function Receipt({
|
||||
item,
|
||||
undone,
|
||||
canUndo,
|
||||
onUndo,
|
||||
}: {
|
||||
item: ChangeItem;
|
||||
undone: boolean;
|
||||
canUndo: boolean;
|
||||
onUndo: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { onShowOnMap } = useAssistantView();
|
||||
if (item.state === 'pending') return null;
|
||||
const receipt = undone ? RECEIPT.discarded : RECEIPT[item.state];
|
||||
const { Icon } = receipt;
|
||||
const applied = item.state === 'applied' && !undone ? item.applied : null;
|
||||
const mappable = applied !== null && ON_MAP.has(applied.entity);
|
||||
const settled = item.state === 'discarded' || item.state === 'expired' || undone;
|
||||
const statusLabel = t(undone ? 'assistant.change.undone' : receipt.labelKey);
|
||||
return (
|
||||
<div className="trek-assistant-enter flex items-center gap-2.5 py-1.5">
|
||||
<KindTile verbKey={item.preview?.verbKey ?? null} size="sm" dimmed={settled} />
|
||||
<div className={`min-w-0 flex-1 ${settled ? 'opacity-60' : ''}`}>
|
||||
<ChangeHead preview={item.preview} struck={settled} />
|
||||
</div>
|
||||
{applied && mappable && onShowOnMap && (
|
||||
<Tooltip label={t('assistant.change.showOnMap')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShowOnMap(applied)}
|
||||
aria-label={t('assistant.change.showOnMap')}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<MapPin size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canUndo && (
|
||||
<Tooltip label={t('assistant.change.undo')} placement="top">
|
||||
<button type="button" onClick={onUndo} aria-label={t('assistant.change.undo')} className={ICON_BUTTON}>
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={statusLabel} placement="top">
|
||||
<span
|
||||
role="img"
|
||||
aria-label={statusLabel}
|
||||
className={`flex h-7 w-5 items-center justify-center ${receipt.tone}`}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.4} className={item.state === 'applying' ? 'animate-spin' : ''} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A change waiting for its decision, as a row of the planner's day list: the
|
||||
* round choice in front (only when there is more than one to choose from),
|
||||
* what it touches in bold, and a faint line saying what happens and when.
|
||||
* Resting on it lights its pin on the map.
|
||||
*/
|
||||
function PendingRow({
|
||||
item,
|
||||
excluded,
|
||||
withChoice,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
item: ChangeItem;
|
||||
excluded: boolean;
|
||||
withChoice: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const { litFromMap, handlers } = useChangeHover(tripId, item.preview?.effect ? item.toolCallId : null);
|
||||
const preview = item.preview;
|
||||
return (
|
||||
<div
|
||||
{...handlers}
|
||||
className={`-mx-1.5 flex items-center gap-2.5 rounded-[12px] px-1.5 py-2 transition-[opacity,background-color] ${
|
||||
excluded ? 'opacity-50' : ''
|
||||
} ${litFromMap ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
|
||||
>
|
||||
{withChoice && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={disabled}
|
||||
aria-pressed={!excluded}
|
||||
aria-label={t('assistant.change.include')}
|
||||
className={`grid h-[18px] w-[18px] flex-none place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 ${
|
||||
excluded ? 'border-[1.5px] border-edge bg-surface-card' : 'bg-accent'
|
||||
}`}
|
||||
>
|
||||
{!excluded && <Check size={11} strokeWidth={3} aria-hidden="true" className="text-accent-text" />}
|
||||
</button>
|
||||
)}
|
||||
<KindTile verbKey={preview?.verbKey ?? null} size="sm" dimmed={excluded} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{preview ? (
|
||||
<>
|
||||
<bdi className="block truncate text-body font-semibold leading-tight text-content">{preview.subject}</bdi>
|
||||
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
|
||||
<span className="flex-shrink-0">{verbLabel(preview, t)}</span>
|
||||
{preview.domain && (
|
||||
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
|
||||
{t(`assistant.domain.${preview.domain}`)}
|
||||
</span>
|
||||
)}
|
||||
<AssistantChangeFields rows={preview.rows} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's head: what the card is, what can be done with it as a whole, and how many changes it holds. */
|
||||
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-1 flex min-h-7 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-content-secondary">
|
||||
{t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||
</span>
|
||||
{actions}
|
||||
<span className="text-caption tabular-nums text-content-faint">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** On the phone the map hides behind the chat: this shrinks the chat and frames what the card would change. */
|
||||
function ViewOnMapButton({ changes }: { changes: readonly DayChange[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const requestCamera = useAssistantStore((s) => s.requestCamera);
|
||||
const setMinimized = useAssistantStore((s) => s.setMinimized);
|
||||
const show = () => {
|
||||
const { assignments, places } = useTripStore.getState();
|
||||
requestCamera(tripId, changePoints(changes, assignments, places), 'fit', 'user');
|
||||
setMinimized(true);
|
||||
};
|
||||
return (
|
||||
<Tooltip label={t('assistant.change.viewOnMap')} placement="top">
|
||||
<button type="button" onClick={show} aria-label={t('assistant.change.viewOnMap')} className={ICON_BUTTON}>
|
||||
<MapPin size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Takes back every change of the block that still can be, the last one first, and says how it went. */
|
||||
function UndoAllButton({ toolCallIds }: { toolCallIds: readonly string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { tripId } = useAssistantView();
|
||||
const undoBlock = useAssistantStore((s) => s.undoBlock);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { done, stale, failed } = await undoBlock(tripId, toolCallIds);
|
||||
if (failed > 0) toast.error(t('assistant.change.undoAllFailed', { count: failed }));
|
||||
else if (stale > 0) toast.warning(t('assistant.change.undoAllPartial', { done, stale }));
|
||||
else toast.success(t('assistant.change.undoneAll', { count: done }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Tooltip label={t('assistant.change.undoAll')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void run()}
|
||||
aria-label={t('assistant.change.undoAll')}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
|
||||
) : (
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the head offers: on a waiting card the way to the map on the phone, on a receipt the block as a whole. */
|
||||
function HeadActions({
|
||||
open,
|
||||
items,
|
||||
changes,
|
||||
undoable,
|
||||
}: {
|
||||
open: boolean;
|
||||
items: readonly ChangeItem[];
|
||||
changes: readonly DayChange[];
|
||||
undoable: readonly string[];
|
||||
}) {
|
||||
const { tripId, variant, onShowOnMap } = useAssistantView();
|
||||
const here = useTripStore((s) => s.trip?.id === tripId);
|
||||
if (open) {
|
||||
const toMap = variant === 'fullscreen' && !!onShowOnMap && here && changes.length > 0;
|
||||
return (
|
||||
<>
|
||||
<AssistantFitCheck changes={changes} />
|
||||
{toMap && <ViewOnMapButton changes={changes} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{onShowOnMap && <AssistantCompareSwitch tripId={tripId} blockKey={blockKeyOf(items.map((i) => i.toolCallId))} />}
|
||||
{undoable.length >= 2 && <UndoAllButton toolCallIds={undoable} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantChangeCard({ items }: { items: ChangeItem[] }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { tripId } = useAssistantView();
|
||||
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
|
||||
const undone = useAssistantStore((s) => chatOf(s, tripId).undone);
|
||||
const decide = useAssistantStore((s) => s.decide);
|
||||
const undo = useAssistantStore((s) => s.undo);
|
||||
const [excluded, setExcluded] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const now = useUndoClock(items);
|
||||
|
||||
const pending = items.filter((i) => i.state === 'pending');
|
||||
const chosen = pending.filter((i) => !excluded.has(i.toolCallId)).length;
|
||||
const changes = useMemo(() => dayChangesOf(items, excluded), [items, excluded]);
|
||||
const undoable = items.filter((i) => undoableNow(i, undone, now)).map((i) => i.toolCallId);
|
||||
|
||||
const week = useWeekDays(pending);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
// A plan over a week reads from its grid; the single changes wait behind one line.
|
||||
const folded = week.length > 0 && !showAll;
|
||||
|
||||
const toggleDay = (ids: readonly string[], include: boolean) =>
|
||||
setExcluded((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of ids) {
|
||||
if (include) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggle = (toolCallId: string) =>
|
||||
setExcluded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(toolCallId)) next.add(toolCallId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const submit = (applyChosen: boolean) =>
|
||||
decide(
|
||||
tripId,
|
||||
pending.map((i) => ({ toolCallId: i.toolCallId, approved: applyChosen && !excluded.has(i.toolCallId) }))
|
||||
);
|
||||
|
||||
// An undone change reads as undone from the store; only what stays needs a word.
|
||||
const runUndo = async (toolCallId: string) => {
|
||||
const outcome = await undo(tripId, toolCallId);
|
||||
if (outcome === 'stale') toast.warning(t('assistant.change.undoStale'));
|
||||
else if (outcome === 'failed') toast.error(t('assistant.change.undoFailed'));
|
||||
};
|
||||
|
||||
const open = pending.length > 0;
|
||||
return (
|
||||
<section className="trek-popover-enter rounded-xl border border-edge-faint bg-surface-card px-3 pb-2 pt-2.5 shadow-card">
|
||||
<CardHead
|
||||
open={open}
|
||||
count={items.length}
|
||||
actions={<HeadActions open={open} items={items} changes={changes} undoable={undoable} />}
|
||||
/>
|
||||
{open && week.length > 0 && (
|
||||
<AssistantWeekOverview days={week} excluded={excluded} disabled={isRunning} onToggleDay={toggleDay} />
|
||||
)}
|
||||
{folded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(true)}
|
||||
className="mb-1 w-full rounded-lg px-2 py-1.5 text-center text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
{t('assistant.week.showAll', { count: pending.length })}
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
{items.map((item) =>
|
||||
item.state === 'pending' ? (
|
||||
folded ? null : (
|
||||
<PendingRow
|
||||
key={item.toolCallId}
|
||||
item={item}
|
||||
excluded={excluded.has(item.toolCallId)}
|
||||
withChoice={pending.length > 1}
|
||||
disabled={isRunning}
|
||||
onToggle={() => toggle(item.toolCallId)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Receipt
|
||||
key={item.toolCallId}
|
||||
item={item}
|
||||
undone={!!undone[item.toolCallId]}
|
||||
canUndo={undoable.includes(item.toolCallId)}
|
||||
onUndo={() => void runUndo(item.toolCallId)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<>
|
||||
<p className="sr-only" role="status">
|
||||
{t('assistant.change.pending', { count: pending.length })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 border-t border-edge-faint pt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRunning}
|
||||
onClick={() => void submit(false)}
|
||||
className="rounded-lg px-3 py-1.5 text-body text-content-secondary hover:bg-surface-hover hover:text-content disabled:opacity-50"
|
||||
>
|
||||
{t('assistant.change.discard')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRunning || chosen === 0}
|
||||
onClick={() => void submit(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-body font-medium text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Hash,
|
||||
ListOrdered,
|
||||
LogIn,
|
||||
LogOut,
|
||||
type LucideIcon,
|
||||
Tag,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
/**
|
||||
* The few facts that tell a change apart at a glance, on the faint line under
|
||||
* its name the way the planner's day list shows a place: when, what kind,
|
||||
* what it costs, a new order. A value that changes reads old to new. The rest
|
||||
* of what the change sets is not listed: the planner shows it once applied.
|
||||
*/
|
||||
|
||||
/** The fields worth a place on the line, each with its glyph, in this order. */
|
||||
const KEY_FIELDS: Partial<Record<AssistantChangeLabelKey, LucideIcon>> = {
|
||||
day: CalendarDays,
|
||||
time: Clock,
|
||||
endTime: Clock,
|
||||
checkIn: LogIn,
|
||||
checkOut: LogOut,
|
||||
dueDate: CalendarClock,
|
||||
category: Tag,
|
||||
price: Wallet,
|
||||
quantity: Hash,
|
||||
order: ListOrdered,
|
||||
};
|
||||
const KEY_ORDER = Object.keys(KEY_FIELDS) as AssistantChangeLabelKey[];
|
||||
const MAX_FACTS = 3;
|
||||
|
||||
/** "#3" is how a day comes from the server; it reads as "Day 3". */
|
||||
function shown(row: AssistantChangeRow, value: string | null | undefined, dayWord: string): string {
|
||||
if (row.labelKey === 'day' && value && /^#\d+$/.test(value)) return `${dayWord} ${value.slice(1)}`;
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
export default function AssistantChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
|
||||
const { t } = useTranslation();
|
||||
const dayWord = t('assistant.week.day');
|
||||
const facts = rows
|
||||
.filter((row) => row.labelKey in KEY_FIELDS && row.after)
|
||||
.sort((a, b) => KEY_ORDER.indexOf(a.labelKey) - KEY_ORDER.indexOf(b.labelKey))
|
||||
.slice(0, MAX_FACTS);
|
||||
if (facts.length === 0) return null;
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
{facts.map((row, i) => {
|
||||
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
|
||||
const changed = row.op !== 'add' && !!row.before;
|
||||
return (
|
||||
<span key={`${row.labelKey}-${i}`} className="inline-flex min-w-0 items-center gap-1">
|
||||
<Icon size={11} aria-hidden="true" className="flex-shrink-0" />
|
||||
{changed && (
|
||||
<>
|
||||
<bdi className="truncate line-through">{shown(row, row.before, dayWord)}</bdi>
|
||||
<ArrowRight size={10} aria-hidden="true" className="flex-shrink-0 rtl:-scale-x-100" />
|
||||
</>
|
||||
)}
|
||||
<bdi className="truncate text-content-muted">{shown(row, row.after, dayWord)}</bdi>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
|
||||
/**
|
||||
* "Before" and "after" of the block of changes applied last, as a small
|
||||
* segmented switch: "before" lays the days it touched back the way they were
|
||||
* on the map, "after" shows the plan as it is. Only there while this session
|
||||
* holds the plan from before the block ran; a thread opened after a reload has
|
||||
* no switch. Without a `blockKey` it follows whichever block is compared.
|
||||
* `tall` gives each side a thumb's room, for the phone, where a tooltip
|
||||
* would never show.
|
||||
*/
|
||||
|
||||
const MODES = [
|
||||
{ mode: 'before', labelKey: 'assistant.change.before' },
|
||||
{ mode: 'after', labelKey: 'assistant.change.after' },
|
||||
] as const;
|
||||
|
||||
interface AssistantCompareSwitchProps {
|
||||
tripId: number;
|
||||
blockKey?: string;
|
||||
tall?: boolean;
|
||||
}
|
||||
|
||||
export default function AssistantCompareSwitch({ tripId, blockKey, tall = false }: AssistantCompareSwitchProps) {
|
||||
const { t } = useTranslation();
|
||||
const current = useAssistantStore((s) => {
|
||||
const compare = chatOf(s, tripId).compare;
|
||||
return compare && (blockKey === undefined || compare.blockKey === blockKey) ? compare.mode : null;
|
||||
});
|
||||
const setCompare = useAssistantStore((s) => s.setCompare);
|
||||
if (!current) return null;
|
||||
const label = t('assistant.change.compare');
|
||||
const group = (
|
||||
<div role="group" aria-label={label} className="inline-flex flex-shrink-0 rounded-lg bg-surface-secondary p-0.5">
|
||||
{MODES.map(({ mode, labelKey }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
aria-pressed={current === mode}
|
||||
onClick={() => setCompare(tripId, mode)}
|
||||
className={`rounded-md text-caption font-medium transition-colors ${
|
||||
tall ? 'h-9 min-w-[44px] px-3' : 'px-2 py-0.5'
|
||||
} ${current === mode ? 'bg-surface-card text-content shadow-card' : 'text-content-muted hover:text-content'}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (tall) return group;
|
||||
return (
|
||||
<Tooltip label={label} placement="top">
|
||||
{group}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* FE-AI-MENTION-001 to FE-AI-MENTION-007
|
||||
*
|
||||
* `@` in the composer on the real @assistant-ui runtime: the list of the
|
||||
* trip's days, places and bookings opens only at the start of a word, Enter
|
||||
* picks from it instead of sending, Escape closes it and keeps the text, and
|
||||
* a pick leaves `@label` in the text, with no chip above it: the text is what
|
||||
* the question names.
|
||||
*/
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildDay, buildPlace, buildReservation, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantSurface from './AssistantSurface';
|
||||
|
||||
const TRIP = 4;
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const INITIAL_TRIP = useTripStore.getState();
|
||||
|
||||
const STATUS: AiStatusResponse = {
|
||||
available: true,
|
||||
policy: 'instance',
|
||||
canConnectOwn: false,
|
||||
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
|
||||
own: null,
|
||||
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
|
||||
quota: null,
|
||||
privacyAcked: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
openrouterOAuth: false,
|
||||
};
|
||||
|
||||
const send = vi.fn(async () => {});
|
||||
|
||||
function seed(tripInPlanner = TRIP) {
|
||||
useAssistantStore.setState({
|
||||
status: STATUS,
|
||||
send,
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages: [] } },
|
||||
});
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: tripInPlanner, title: 'Paris' }),
|
||||
days: [buildDay({ id: 11, title: 'Arrival', date: '2026-11-02' }), buildDay({ id: 12, title: null, date: null })],
|
||||
places: [buildPlace({ id: 21, name: 'Louvre' }), buildPlace({ id: 22, name: 'Bouillon Pigalle' })],
|
||||
reservations: [buildReservation({ id: 31, title: 'Dinner at Le Train Bleu', day_id: 11 })],
|
||||
});
|
||||
}
|
||||
|
||||
async function input(): Promise<HTMLTextAreaElement> {
|
||||
render(<AssistantSurface tripId={TRIP} tripTitle="Paris" variant="window" />);
|
||||
return (await screen.findByRole('textbox', { name: 'Ask anything about Paris' })) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
const mentions = () => chatOf(useAssistantStore.getState(), TRIP).mentions;
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom lays nothing out and has no element scrolling; the viewport only needs the call to exist.
|
||||
Element.prototype.scrollTo = vi.fn() as unknown as typeof Element.prototype.scrollTo;
|
||||
});
|
||||
|
||||
beforeEach(() => send.mockClear());
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
useTripStore.setState(INITIAL_TRIP, true);
|
||||
});
|
||||
|
||||
describe('AssistantComposer mentions', () => {
|
||||
it('FE-AI-MENTION-001: typing after @ lists matches, and Enter picks one instead of sending', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
expect(screen.getByText('Type @ to mention days, places and bookings.')).toBeInTheDocument();
|
||||
fireEvent.change(field, { target: { value: 'Is @lou' } });
|
||||
|
||||
expect(await screen.findByRole('option', { name: 'Louvre' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'Bouillon Pigalle' })).toBeNull();
|
||||
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
await waitFor(() => expect(field.value).toBe('Is @Louvre '));
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
// A browser reports where the caret went after the pick; jsdom does not on its own.
|
||||
fireEvent.select(field);
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
|
||||
expect(mentions()).toEqual([{ kind: 'place', id: 21, label: 'Louvre' }]);
|
||||
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Is @Louvre ');
|
||||
// The pick shows in the text only, never as a chip above it.
|
||||
expect(screen.queryByRole('button', { name: 'Remove context' })).toBeNull();
|
||||
|
||||
fireEvent.change(field, { target: { value: 'Is @Louvre open late?' } });
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Is @Louvre open late?'));
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-002: a bare @ offers the kinds, and a kind opens its entries', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: '@' } });
|
||||
|
||||
expect(await screen.findByRole('option', { name: 'Days' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Places' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Bookings' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Days' }));
|
||||
expect(await screen.findByRole('option', { name: /Arrival/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Day 2' })).toBeInTheDocument();
|
||||
|
||||
// The heading leads back to the kinds.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Days' }));
|
||||
expect(await screen.findByRole('option', { name: 'Places' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Days' }));
|
||||
|
||||
fireEvent.click(await screen.findByRole('option', { name: /Arrival/ }));
|
||||
await waitFor(() => expect(field.value).toBe('@Arrival '));
|
||||
expect(mentions()).toEqual([{ kind: 'day', id: 11, label: 'Arrival' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-003: Escape closes the list and keeps what was typed', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: 'near @bou' } });
|
||||
expect(await screen.findByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(field, { key: 'Escape' });
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
|
||||
expect(field.value).toBe('near @bou');
|
||||
expect(mentions()).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-004: @ inside a word, such as an email address, opens nothing', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: 'mail@lou' } });
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-005: says so when nothing matches', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: '@zzz' } });
|
||||
expect(await screen.findByText('Nothing found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-006: a mention is taken back by deleting its @label, with no chip row to keep in step', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: 'Dinner after @Din' } });
|
||||
fireEvent.click(await screen.findByRole('option', { name: /Dinner at Le Train Bleu/ }));
|
||||
await waitFor(() => expect(field.value).toBe('Dinner after @Dinner at Le Train Bleu '));
|
||||
expect(screen.queryByRole('button', { name: 'Remove context' })).toBeNull();
|
||||
|
||||
fireEvent.change(field, { target: { value: 'Dinner after ' } });
|
||||
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Dinner after ');
|
||||
expect(screen.queryByText('Dinner at Le Train Bleu')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-007: for a trip the planner does not show, @ stays plain text', async () => {
|
||||
seed(99);
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: '@lou' } });
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
expect(screen.queryByText('Type @ to mention days, places and bookings.')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { AuiIf, ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react';
|
||||
import { ArrowUp, CalendarDays, MapPin, Paperclip, Square, X } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import PlaceAvatar from '../shared/PlaceAvatar';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import AssistantMentionPopover from './AssistantMentionPopover';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { useAssistantAttach } from './useAssistantAttach';
|
||||
import { useAssistantFocus } from './useAssistantFocus';
|
||||
import { useAssistantMentions } from './useAssistantMentions';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
import { useTypewriterPlaceholder } from './useTypewriterPlaceholder';
|
||||
|
||||
/** Send and Stop share one spot; on the phone it is a full thumb's size. */
|
||||
const ACTION = {
|
||||
window: 'h-9 w-9',
|
||||
fullscreen: 'h-11 w-11',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Where the user writes. One rounded shell with the text and, in the same
|
||||
* spot, Send or Stop. Enter sends, Shift+Enter breaks the line, Escape stops
|
||||
* a running answer. The one chip above the text is the focus the question is
|
||||
* about, and can be removed. `@` at the start of a word opens the trip's days,
|
||||
* places and bookings to name; a pick stays as `@label` in the text, which is
|
||||
* what the question names, and shows as a chip only in the sent bubble. While
|
||||
* that list is open, Enter picks and Escape closes it. While the field is empty,
|
||||
* its placeholder writes out things worth asking about this trip, one after
|
||||
* another.
|
||||
*
|
||||
* The text typed so far lives in the assistant store too, so it survives the
|
||||
* window closing or the phone hopping to the map and back.
|
||||
*/
|
||||
export default function AssistantComposer({ tripId, tripTitle }: { tripId: number; tripTitle: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const aui = useAui();
|
||||
const setDraft = useAssistantStore((s) => s.setDraft);
|
||||
const { label, kind, place, clear } = useAssistantFocus(tripId);
|
||||
const mentions = useAssistantMentions(tripId);
|
||||
const { suggestions } = useAssistantSuggestions(tripId);
|
||||
const empty = useAuiState((s) => s.composer.isEmpty);
|
||||
const { take, busy, accept } = useAssistantAttach(tripId);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
|
||||
const placeholder = useTypewriterPlaceholder(
|
||||
suggestions.map((s) => s.prompt),
|
||||
fallback,
|
||||
empty
|
||||
);
|
||||
|
||||
// Takes back what was typed before the chat was last shown.
|
||||
useEffect(() => {
|
||||
const draft = chatOf(useAssistantStore.getState(), tripId).draft;
|
||||
if (draft) aui.composer.setText(draft);
|
||||
}, [aui, tripId]);
|
||||
|
||||
const FocusIcon = kind === 'place' ? MapPin : CalendarDays;
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="relative rounded-2xl border border-edge-faint bg-surface-card shadow-card transition-colors focus-within:border-edge">
|
||||
<ComposerPrimitive.TriggerPopoverRoot>
|
||||
<AssistantMentionPopover adapter={mentions.adapter} onInserted={mentions.onInserted} />
|
||||
{label && (
|
||||
<div className="flex flex-wrap gap-1 px-2 pt-2">
|
||||
<ContextChip
|
||||
label={label}
|
||||
icon={place ? <PlaceAvatar place={place} size={18} /> : <FocusIcon size={12} aria-hidden="true" />}
|
||||
round={!!place}
|
||||
onRemove={clear}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 p-1.5">
|
||||
<Tooltip label={t('assistant.attach.button')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
aria-label={t('assistant.attach.button')}
|
||||
className={`flex ${ACTION[variant]} flex-shrink-0 items-center justify-center rounded-full text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40`}
|
||||
>
|
||||
<Paperclip size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept={accept}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
take(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<ComposerPrimitive.Input
|
||||
// The desktop window opens ready to type; on the phone the keyboard waits for a tap.
|
||||
autoFocus={variant === 'window'}
|
||||
dir="auto"
|
||||
minRows={1}
|
||||
maxRows={8}
|
||||
submitMode="enter"
|
||||
addAttachmentOnPaste={false}
|
||||
enterKeyHint="send"
|
||||
aria-label={fallback}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(tripId, e.target.value)}
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files.length > 0 && take(e.clipboardData.files)) e.preventDefault();
|
||||
}}
|
||||
className="max-h-48 min-h-[36px] flex-1 resize-none bg-transparent px-1.5 py-1.5 text-body text-content outline-none placeholder:text-content-faint disabled:cursor-not-allowed dark:bg-transparent"
|
||||
/>
|
||||
<AuiIf condition={(s) => s.thread.isRunning}>
|
||||
<ComposerPrimitive.Cancel
|
||||
aria-label={t('assistant.composer.stop')}
|
||||
className={`flex ${ACTION[variant]} flex-shrink-0 items-center justify-center rounded-full bg-accent text-accent-text transition-transform hover:scale-105 hover:bg-accent-hover active:scale-95`}
|
||||
>
|
||||
<Square size={12} fill="currentColor" aria-hidden="true" />
|
||||
</ComposerPrimitive.Cancel>
|
||||
</AuiIf>
|
||||
<AuiIf condition={(s) => !s.thread.isRunning}>
|
||||
<SendButton size={ACTION[variant]} />
|
||||
</AuiIf>
|
||||
</div>
|
||||
</ComposerPrimitive.TriggerPopoverRoot>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/** One thing the question is about, above the text, with the cross that takes it back. */
|
||||
function ContextChip({
|
||||
label,
|
||||
icon,
|
||||
round,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
/** A place's picture fills the chip's round end, so the chip starts closer to it. */
|
||||
round: boolean;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-full bg-surface-secondary py-0.5 pe-1 text-caption text-content-secondary ${round ? 'ps-0.5' : 'ps-2'}`}
|
||||
>
|
||||
<span className="flex flex-shrink-0">{icon}</span>
|
||||
<bdi className="truncate">{label}</bdi>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={t('assistant.context.remove')}
|
||||
className="rounded-full p-0.5 text-content-faint hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SendButton({ size }: { size: string }) {
|
||||
const { t } = useTranslation();
|
||||
// Sending waits while a proposed change is undecided; the tooltip says so.
|
||||
const blocked = useAuiState((s) => s.thread.isSendDisabled);
|
||||
const button = (
|
||||
<ComposerPrimitive.Send
|
||||
aria-label={t('assistant.composer.send')}
|
||||
className={`flex ${size} flex-shrink-0 items-center justify-center rounded-full bg-accent text-accent-text shadow-card transition-[transform,opacity,box-shadow] duration-150 hover:scale-105 hover:bg-accent-hover hover:shadow-dropdown active:scale-95 disabled:scale-100 disabled:opacity-30 disabled:shadow-none`}
|
||||
>
|
||||
<ArrowUp size={17} strokeWidth={2.4} aria-hidden="true" />
|
||||
</ComposerPrimitive.Send>
|
||||
);
|
||||
// A disabled button gets no pointer events, so the tooltip that says why hangs on a wrapper.
|
||||
return blocked ? (
|
||||
<Tooltip label={t('assistant.composer.decideFirst')} placement="top">
|
||||
<span className="inline-flex">{button}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { AssistantChangeEffect, AssistantMessage } from '@trek/shared';
|
||||
import { ArrowRightLeft, Minus, Sparkles } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { changeEffects } from '../../store/assistantMapModel';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
|
||||
/** What a waiting change does to one day, as a row of the preview. */
|
||||
interface PreviewRow {
|
||||
toolCallId: string;
|
||||
kind: 'add' | 'in' | 'remove' | 'out';
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** The changes no one has decided on yet, by their call. */
|
||||
function waitingCalls(messages: readonly AssistantMessage[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const m of messages) {
|
||||
for (const p of m.parts) {
|
||||
if (p.type === 'tool-call' && p.approval && !p.approval.resolution && p.approval.approved === undefined) {
|
||||
ids.add(p.toolCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function rowOf(
|
||||
toolCallId: string,
|
||||
effect: AssistantChangeEffect,
|
||||
dayId: number,
|
||||
nameOf: (id: number | null) => string | null
|
||||
) {
|
||||
const name = effect.place?.name ?? nameOf(effect.placeId);
|
||||
if (!name) return null;
|
||||
if (effect.dayId === dayId) {
|
||||
const kind = effect.kind === 'remove' ? 'remove' : effect.kind === 'move' ? 'in' : 'add';
|
||||
return { toolCallId, kind, name } satisfies PreviewRow;
|
||||
}
|
||||
if (effect.kind === 'move' && effect.fromDayId === dayId)
|
||||
return { toolCallId, kind: 'out', name } satisfies PreviewRow;
|
||||
return null;
|
||||
}
|
||||
|
||||
const LOOK: Record<PreviewRow['kind'], { icon: typeof Sparkles; row: string; name: string; badgeKey: string }> = {
|
||||
add: {
|
||||
icon: Sparkles,
|
||||
row: 'border-dashed border-accent bg-accent-subtle',
|
||||
name: 'text-content',
|
||||
badgeKey: 'assistant.preview.suggested',
|
||||
},
|
||||
in: {
|
||||
icon: ArrowRightLeft,
|
||||
row: 'border-dashed border-accent bg-accent-subtle',
|
||||
name: 'text-content',
|
||||
badgeKey: 'assistant.preview.movedHere',
|
||||
},
|
||||
remove: {
|
||||
icon: Minus,
|
||||
row: 'border-dashed border-edge',
|
||||
name: 'text-content-faint line-through',
|
||||
badgeKey: 'assistant.preview.removed',
|
||||
},
|
||||
out: {
|
||||
icon: ArrowRightLeft,
|
||||
row: 'border-dashed border-edge',
|
||||
name: 'text-content-faint line-through',
|
||||
badgeKey: 'assistant.preview.movedAway',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The assistant's waiting changes inside the day they touch, in the planner's
|
||||
* own day list: a place it would add as a dashed row, one it would take off
|
||||
* struck through, a move on both of its days. Resting on a row lights the
|
||||
* change's pin and card; a click brings the chat forward to decide. Nothing
|
||||
* here writes; the change card stays the one place a change is applied.
|
||||
*/
|
||||
export default function AssistantDayPreview({ tripId, dayId }: { tripId: number; dayId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
|
||||
const hovered = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null));
|
||||
const { setMapHover, open } = useAssistantStore(useShallow((s) => ({ setMapHover: s.setMapHover, open: s.open })));
|
||||
const places = useTripStore((s) => s.places);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const waiting = waitingCalls(messages);
|
||||
if (waiting.size === 0) return [];
|
||||
const nameOf = (id: number | null) => (id === null ? null : (places.find((p) => p.id === id)?.name ?? null));
|
||||
const out: PreviewRow[] = [];
|
||||
for (const [toolCallId, effect] of changeEffects(messages)) {
|
||||
if (!waiting.has(toolCallId)) continue;
|
||||
const row = rowOf(toolCallId, effect, dayId, nameOf);
|
||||
if (row) out.push(row);
|
||||
}
|
||||
return out;
|
||||
}, [messages, places, dayId]);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<ul className="m-0 flex list-none flex-col gap-1 px-3 pb-2 pt-1" aria-label={t('assistant.preview.title')}>
|
||||
{rows.map((row, i) => {
|
||||
const look = LOOK[row.kind];
|
||||
const Icon = look.icon;
|
||||
const lit = hovered === row.toolCallId;
|
||||
return (
|
||||
<li
|
||||
key={`${row.toolCallId}-${row.kind}`}
|
||||
className="trek-assistant-enter"
|
||||
style={{ animationDelay: `${i * 50}ms` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
onMouseEnter={() => setMapHover(tripId, row.toolCallId, 'card')}
|
||||
onMouseLeave={() => setMapHover(tripId, null, 'card')}
|
||||
className={`flex w-full items-center gap-2 rounded-xl border px-2.5 py-1.5 text-start transition-all ${look.row} ${
|
||||
lit ? 'shadow-md ring-2 ring-accent' : 'hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-lg bg-surface-card text-content-muted shadow-sm">
|
||||
<Icon size={12} aria-hidden="true" />
|
||||
</span>
|
||||
<bdi className={`min-w-0 flex-1 truncate text-caption font-medium ${look.name}`}>{row.name}</bdi>
|
||||
<span className="flex-shrink-0 rounded-full bg-surface-card px-2 py-0.5 text-caption text-content-muted shadow-sm">
|
||||
{t(look.badgeKey)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* FE-AI-ERR-001 to FE-AI-ERR-008
|
||||
*
|
||||
* The error line: one sentence, at most one thing to do, and that thing
|
||||
* does what it says.
|
||||
*/
|
||||
import type { AiStatusResponse, AssistantErrorCode } from '@trek/shared';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { setForcedOffline } from '../../sync/networkMode';
|
||||
import AssistantError from './AssistantError';
|
||||
|
||||
const TRIP = 4;
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
|
||||
const STATUS: AiStatusResponse = {
|
||||
available: true,
|
||||
policy: 'instance_or_user',
|
||||
canConnectOwn: true,
|
||||
effective: { source: 'user', kind: 'anthropic', model: 'claude', host: 'api.anthropic.com', paidBy: 'user' },
|
||||
own: null,
|
||||
instance: null,
|
||||
quota: null,
|
||||
privacyAcked: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
openrouterOAuth: true,
|
||||
};
|
||||
|
||||
const retry = vi.fn(async () => {});
|
||||
const continueHere = vi.fn(async () => {});
|
||||
const send = vi.fn(async () => {});
|
||||
|
||||
function withError(error: AssistantErrorCode | null, status: AiStatusResponse = STATUS) {
|
||||
useAssistantStore.setState({
|
||||
status,
|
||||
retry,
|
||||
continueHere,
|
||||
send,
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), error } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderLine() {
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/trips/4" element={<AssistantError tripId={TRIP} />} />
|
||||
<Route path="/settings" element={<p>settings page</p>} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/trips/4'] }
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => setForcedOffline(false));
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
setForcedOffline(false);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AssistantError', () => {
|
||||
it('FE-AI-ERR-001: shows nothing while all is well', () => {
|
||||
withError(null);
|
||||
const { container } = renderLine();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-002: names the provider and retries a provider that was down', () => {
|
||||
withError('provider_unavailable');
|
||||
renderLine();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Anthropic isn’t responding right now.');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(retry).toHaveBeenCalledWith(TRIP);
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-003: a busy chat continues here, and a long run goes on with "Continue"', () => {
|
||||
withError('busy');
|
||||
const { unmount } = renderLine();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue here' }));
|
||||
expect(continueHere).toHaveBeenCalledWith(TRIP);
|
||||
unmount();
|
||||
|
||||
withError('max_steps');
|
||||
renderLine();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
expect(send).toHaveBeenCalledWith(TRIP, 'Continue');
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-004: an expired sign-in leads straight to the settings card', () => {
|
||||
withError('needs_reauth');
|
||||
renderLine();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in again' }));
|
||||
expect(screen.getByText('settings page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-006: an own key that stopped working says so before anything is sent', () => {
|
||||
withError(null, {
|
||||
...STATUS,
|
||||
own: {
|
||||
scope: 'user',
|
||||
kind: 'anthropic',
|
||||
model: 'claude',
|
||||
hint: '…a1b2',
|
||||
status: 'error',
|
||||
host: 'api.anthropic.com',
|
||||
},
|
||||
});
|
||||
renderLine();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Replace key' }));
|
||||
expect(screen.getByText('settings page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-007: a provider limit names the provider, without guessing at its plan', () => {
|
||||
withError('rate_limited');
|
||||
renderLine();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Limit reached at Anthropic. Try again later.');
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-008: waiting changes ask for a decision before a place can be suggested', () => {
|
||||
withError('decide_first');
|
||||
renderLine();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Decide on the open suggestions first.');
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-005: offline wins over any other error and offers nothing to press', () => {
|
||||
withError('provider_unavailable');
|
||||
setForcedOffline(true);
|
||||
renderLine();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline. The assistant needs internet.');
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CircleAlert, WifiOff } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { type AssistantErrorActionKind, connectionErrorOf, errorView } from './assistantModel';
|
||||
import { AI_SETTINGS_PATH } from './assistantParts';
|
||||
import { CHATGPT_USAGE_URL, providerLabel } from './providerCatalog';
|
||||
|
||||
/**
|
||||
* Why the assistant cannot go on, as one line above the composer with at
|
||||
* most one thing to do about it: try again, continue, sign in again, and so
|
||||
* on. Offline needs no button; the line says why the composer is off.
|
||||
*/
|
||||
export default function AssistantError({ tripId }: { tripId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { offline } = useNetworkMode();
|
||||
const error = useAssistantStore((s) => chatOf(s, tripId).error);
|
||||
const status = useAssistantStore((s) => s.status);
|
||||
const retry = useAssistantStore((s) => s.retry);
|
||||
const continueHere = useAssistantStore((s) => s.continueHere);
|
||||
const send = useAssistantStore((s) => s.send);
|
||||
|
||||
const code = offline ? 'offline' : (error ?? connectionErrorOf(status));
|
||||
if (!code) return null;
|
||||
|
||||
const effective = status?.effective;
|
||||
const provider = effective ? providerLabel(effective.kind, effective.host) : '';
|
||||
const view = errorView(code, { canConnectOwn: status?.canConnectOwn ?? false, provider });
|
||||
|
||||
const run = (kind: AssistantErrorActionKind) => {
|
||||
if (kind === 'retry') void retry(tripId);
|
||||
else if (kind === 'continueHere') void continueHere(tripId);
|
||||
// The model asked whether to go on; the answer is the button's own word.
|
||||
else if (kind === 'continue') void send(tripId, t('assistant.errorAction.continue'));
|
||||
else if (kind === 'openSettings') void navigate(AI_SETTINGS_PATH);
|
||||
else window.open(CHATGPT_USAGE_URL, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const { action } = view;
|
||||
const Icon = code === 'offline' ? WifiOff : CircleAlert;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="trek-assistant-enter flex items-center gap-2.5 rounded-lg bg-surface-secondary px-3 py-2 text-caption text-content-secondary"
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" className="flex-shrink-0 text-warning" />
|
||||
<span className="min-w-0 flex-1">{t(view.messageKey, { provider })}</span>
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => run(action.kind)}
|
||||
className="flex-shrink-0 rounded-md px-2 py-1 text-caption font-medium text-accent-on hover:bg-surface-hover"
|
||||
>
|
||||
{t(action.labelKey)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CircleCheck, TriangleAlert } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { buildDayAfter, changedDays, type DayChange, type TimelineStop } from './dayAfterModel';
|
||||
|
||||
/** Lines the tooltip names; the rest are only counted on the pill. */
|
||||
const MAX_LINES = 3;
|
||||
|
||||
/** What does not fit on a stop of the day: arriving after the next thing starts, or a way too tight. */
|
||||
function problemOf(stop: TimelineStop, t: (key: string, params?: Record<string, string | number>) => string) {
|
||||
const lead = stop.lead;
|
||||
if (lead?.overlapsWith) return t('assistant.change.timeline.overlap', { place: lead.overlapsWith });
|
||||
if (lead?.shortBy != null) return `${stop.name}: ${t('assistant.change.timeline.tight', { count: lead.shortBy })}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the days a waiting card changes still work out in time, as one
|
||||
* small pill in the card's head: "fits" in green, or how many things to look
|
||||
* at in amber, with what they are on hover. The days themselves show the
|
||||
* suggestion in the planner's day list, so the card does not draw them again.
|
||||
* Shown only while the planner holds this trip, whose day plans it reads.
|
||||
*/
|
||||
export default function AssistantFitCheck({ changes }: { changes: readonly DayChange[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const plan = useTripStore(
|
||||
useShallow((s) => ({ here: s.trip?.id === tripId, days: s.days, assignments: s.assignments, places: s.places }))
|
||||
);
|
||||
|
||||
const check = useMemo(() => {
|
||||
if (!plan.here || changes.length === 0) return null;
|
||||
const stops = changedDays(changes).flatMap((dayId) => {
|
||||
const day = plan.days.find((d) => d.id === dayId);
|
||||
if (!day) return [];
|
||||
return buildDayAfter({
|
||||
dayId,
|
||||
assignments: plan.assignments,
|
||||
places: plan.places,
|
||||
changes,
|
||||
defaultMode: day.default_transport_mode,
|
||||
}).stops;
|
||||
});
|
||||
const measured = stops.some((stop) => stop.lead?.travelMinutes != null);
|
||||
const problems = stops.map((stop) => problemOf(stop, t)).filter((line): line is string => line !== null);
|
||||
return measured || problems.length > 0 ? problems : null;
|
||||
}, [plan, changes, t]);
|
||||
|
||||
if (!check) return null;
|
||||
const ok = check.length === 0;
|
||||
const pill = (
|
||||
<span
|
||||
className={`inline-flex h-6 flex-shrink-0 items-center gap-1 rounded-full px-2 text-caption font-medium ${
|
||||
ok ? 'bg-success-soft text-success' : 'bg-warning-soft text-warning'
|
||||
}`}
|
||||
>
|
||||
{ok ? <CircleCheck size={12} aria-hidden="true" /> : <TriangleAlert size={12} aria-hidden="true" />}
|
||||
{ok ? t('assistant.check.ok') : t('assistant.check.issues', { count: check.length })}
|
||||
</span>
|
||||
);
|
||||
if (ok) return pill;
|
||||
return (
|
||||
<Tooltip label={check.slice(0, MAX_LINES).join('\n')} placement="top">
|
||||
<span tabIndex={0} className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||
{pill}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { AssistantMessage } from '@trek/shared';
|
||||
import { CornerDownRight } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useCanDo } from '../../store/permissionsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { followUpsFor } from './assistantFollowUpRules';
|
||||
import { hasUndecided } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
|
||||
/** Each chip comes in a moment after the one before it. */
|
||||
const STAGGER_MS = 60;
|
||||
|
||||
/**
|
||||
* The next questions under the latest answer, one tap each. Only under the
|
||||
* last answer, once it is written, and not while changes wait for a decision:
|
||||
* then the cards are what to answer.
|
||||
*/
|
||||
export default function AssistantFollowUps({ message }: { message: AssistantMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const can = useCanDo();
|
||||
const trip = useTripStore((s) => (s.trip?.id === tripId ? s.trip : null));
|
||||
const send = useAssistantStore((s) => s.send);
|
||||
const shown = useAssistantStore((s) => {
|
||||
const chat = chatOf(s, tripId);
|
||||
const last = chat.messages[chat.messages.length - 1];
|
||||
return !chat.isRunning && !chat.error && last?.id === message.id && !hasUndecided(chat.messages);
|
||||
});
|
||||
if (!shown) return null;
|
||||
const chips = followUpsFor(message, !!trip && can('place_edit', trip));
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div role="group" aria-label={t('assistant.followUp.title')} className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
{chips.map((chip, i) => {
|
||||
const text = t(chip.labelKey);
|
||||
return (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
onClick={() => void send(tripId, text)}
|
||||
className="trek-assistant-enter group/chip inline-flex max-w-full items-center gap-1.5 rounded-full bg-surface-card px-3 py-1.5 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md hover:ring-edge active:translate-y-0"
|
||||
style={{ animationDelay: `${i * STAGGER_MS}ms` }}
|
||||
>
|
||||
<CornerDownRight
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 text-content-faint transition-colors group-hover/chip:text-content rtl:-scale-x-100"
|
||||
/>
|
||||
<span className="truncate">{text}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
|
||||
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { PopoverItem } from '../Packing/PackingPopover';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu';
|
||||
import { NEUTRAL_TINT } from '../shared/DialogShell';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import { PopoverListPanel } from '../shared/dialogParts';
|
||||
import { usePopoverList } from '../shared/usePopoverList';
|
||||
import AssistantMemoryDialog from './AssistantMemoryDialog';
|
||||
import { AI_SETTINGS_PATH } from './assistantParts';
|
||||
import { useAssistantHintsSetting } from './useAssistantHints';
|
||||
import { useAssistantLiveStep } from './useAssistantLiveStep';
|
||||
import type { useAssistantThread } from './useAssistantThread';
|
||||
import { useTripMemory } from './useTripMemory';
|
||||
|
||||
// The round, raised buttons of TREK's dialog heads.
|
||||
const ICON_BUTTON =
|
||||
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content';
|
||||
|
||||
interface AssistantHeaderProps {
|
||||
tripId: number;
|
||||
thread: ReturnType<typeof useAssistantThread>;
|
||||
tripTitle: string;
|
||||
onClose: () => void;
|
||||
/** Moves the window: spread on the title, which is the part of the header to grab. */
|
||||
dragProps: HTMLAttributes<HTMLElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The window's head, in the look of TREK's dialog heads: a tinted band with
|
||||
* a small line naming the assistant over the conversation's name; the band is where the window is
|
||||
* grabbed to move it. On the end side the recent chats (a new chat starts at
|
||||
* the top of that list), more and close, as round raised buttons. Closing
|
||||
* keeps the conversation, and an answer being written goes on. The model is not picked here;
|
||||
* that lives in the settings.
|
||||
*/
|
||||
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const live = useAssistantLiveStep(tripId);
|
||||
const memory = useTripMemory(tripId);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const menu = useContextMenu();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const threads = thread.threads ?? [];
|
||||
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
|
||||
const { open: historyOpen, triggerRef: historyTrigger, listId: historyListId } = history;
|
||||
const hints = useAssistantHintsSetting();
|
||||
|
||||
const openHistory = () => {
|
||||
if (!history.open) thread.refreshThreads();
|
||||
history.toggle();
|
||||
};
|
||||
|
||||
const pick = (run: () => void) => {
|
||||
history.close();
|
||||
run();
|
||||
};
|
||||
|
||||
const openMenu = (e: MouseEvent) =>
|
||||
menu.open(
|
||||
e,
|
||||
[
|
||||
{
|
||||
label: t('assistant.history.new'),
|
||||
icon: Plus,
|
||||
disabled: thread.threadId === null && !thread.isRunning,
|
||||
onClick: thread.newThread,
|
||||
},
|
||||
{
|
||||
label: t(hints.on ? 'assistant.hint.menuOff' : 'assistant.hint.menuOn'),
|
||||
icon: hints.on ? LightbulbOff : Lightbulb,
|
||||
onClick: hints.toggle,
|
||||
},
|
||||
{ label: t('assistant.window.settings'), icon: Settings, onClick: () => void navigate(AI_SETTINGS_PATH) },
|
||||
{
|
||||
label: t('assistant.window.deleteThread'),
|
||||
icon: Trash2,
|
||||
danger: true,
|
||||
disabled: thread.threadId === null || thread.isRunning,
|
||||
onClick: () => setConfirming(true),
|
||||
},
|
||||
],
|
||||
true
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex flex-shrink-0 items-center gap-2 border-b border-edge-faint py-2.5 pe-2.5 ps-4"
|
||||
style={{ background: NEUTRAL_TINT }}
|
||||
>
|
||||
<div
|
||||
{...dragProps}
|
||||
className="flex min-w-0 flex-1 cursor-grab touch-none select-none items-center active:cursor-grabbing"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{thread.isRunning ? (
|
||||
// While it works, the line over the title says what it does right now.
|
||||
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption font-medium">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="trek-assistant-pulse h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
|
||||
/>
|
||||
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="m-0 truncate font-geist text-caption font-bold uppercase tracking-[.08em] text-content-muted">
|
||||
{t('assistant.launcher.tooltip')}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="m-0 truncate text-body font-bold tracking-[-0.01em] text-content">
|
||||
<bdi>{thread.title ?? tripTitle}</bdi>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
{/* Buttons are no place to start a drag from. */}
|
||||
<div
|
||||
className="flex flex-shrink-0 items-center gap-1"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMemoryOpen(true)}
|
||||
aria-label={t('assistant.memory.title')}
|
||||
className={`relative ${ICON_BUTTON}`}
|
||||
>
|
||||
<NotebookPen size={13} aria-hidden="true" />
|
||||
{memory.has && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -end-0.5 -top-0.5 h-2 w-2 rounded-full bg-accent ring-2 ring-[color:var(--bg-elevated)]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('assistant.history.open')}>
|
||||
<button
|
||||
ref={historyTrigger}
|
||||
type="button"
|
||||
onClick={openHistory}
|
||||
aria-label={t('assistant.history.open')}
|
||||
aria-expanded={historyOpen}
|
||||
aria-controls={historyOpen ? historyListId : undefined}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<History size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('assistant.window.menu')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openMenu}
|
||||
aria-label={t('assistant.window.menu')}
|
||||
aria-haspopup="menu"
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<Ellipsis size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.close')}>
|
||||
<button type="button" onClick={onClose} aria-label={t('common.close')} className={ICON_BUTTON}>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<PopoverListPanel list={history} label={t('assistant.history.open')} className="w-72">
|
||||
<PopoverItem
|
||||
icon={<Plus size={14} />}
|
||||
label={t('assistant.history.new')}
|
||||
onClick={() => pick(thread.newThread)}
|
||||
/>
|
||||
{threads.length === 0 ? (
|
||||
<p className="m-0 px-2.5 py-2 text-caption text-content-muted">{t('assistant.history.empty')}</p>
|
||||
) : (
|
||||
threads.map((th) => (
|
||||
<PopoverItem
|
||||
key={th.id}
|
||||
icon={null}
|
||||
label={th.title}
|
||||
active={th.id === thread.threadId}
|
||||
onClick={() => pick(() => thread.openThread(th.id))}
|
||||
trailing={
|
||||
<span className="flex-shrink-0 text-caption text-content-faint">
|
||||
{new Date(th.lastMessageAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{thread.retentionNote && (
|
||||
<p className="m-0 border-t border-edge-faint px-2.5 pb-1 pt-2 text-caption text-content-faint">
|
||||
{thread.retentionNote}
|
||||
</p>
|
||||
)}
|
||||
</PopoverListPanel>
|
||||
|
||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
||||
<ConfirmDialog
|
||||
isOpen={confirming}
|
||||
onClose={() => setConfirming(false)}
|
||||
onConfirm={() => {
|
||||
setConfirming(false);
|
||||
void thread.removeThread();
|
||||
}}
|
||||
title={t('assistant.window.deleteThread')}
|
||||
message={t('assistant.window.deleteConfirm')}
|
||||
confirmLabel={t('common.delete')}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* FE-AI-HINTLIST-001 to FE-AI-HINTLIST-006
|
||||
*
|
||||
* The hint list in the empty chat: three rows at most with the rest behind
|
||||
* "n more", a row that asks, an X that waves one away, rows shown counted as
|
||||
* seen, and rows that wait while offline.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantHints from './AssistantHints';
|
||||
import type { AssistantHintsView, ReadyHint } from './useAssistantHints';
|
||||
|
||||
const network = vi.hoisted(() => ({ offline: false }));
|
||||
vi.mock('../../hooks/useNetworkMode', () => ({
|
||||
useNetworkMode: () => ({ offline: network.offline, forced: false, setForced: () => undefined }),
|
||||
}));
|
||||
|
||||
function hint(id: string, level: ReadyHint['level'] = 'info'): ReadyHint {
|
||||
const rule = level === 'warn' ? 'overlap' : 'gap';
|
||||
return { id, rule, level, text: `Line ${id}`, prompt: `Ask ${id}`, focus: null };
|
||||
}
|
||||
|
||||
function view(hints: ReadyHint[]): AssistantHintsView {
|
||||
return {
|
||||
hints,
|
||||
unseenWarn: 0,
|
||||
markSeen: vi.fn(),
|
||||
markWarnsSeen: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
ask: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
network.offline = false;
|
||||
});
|
||||
|
||||
describe('AssistantHints', () => {
|
||||
it('FE-AI-HINTLIST-001: shows nothing without hints, and three rows with the rest behind "n more"', () => {
|
||||
const { container, unmount } = render(<AssistantHints view={view([])} phone={false} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
unmount();
|
||||
|
||||
const five = view(['a', 'b', 'c', 'd', 'e'].map((id) => hint(id)));
|
||||
render(<AssistantHints view={five} phone={false} />);
|
||||
expect(screen.getByRole('region', { name: 'Worth a look' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
||||
expect(five.markSeen).toHaveBeenLastCalledWith(['a', 'b', 'c']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 more' }));
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(5);
|
||||
expect(five.markSeen).toHaveBeenLastCalledWith(['a', 'b', 'c', 'd', 'e']);
|
||||
});
|
||||
|
||||
it('FE-AI-HINTLIST-002: a row asks its question, and the X waves the hint away', () => {
|
||||
const one = view([hint('a', 'warn')]);
|
||||
render(<AssistantHints view={one} phone={false} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Line a' }));
|
||||
expect(one.ask).toHaveBeenCalledWith(one.hints[0]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide this hint' }));
|
||||
expect(one.dismiss).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
it('FE-AI-HINTLIST-003: on the phone the X is always there, with room for a thumb', () => {
|
||||
render(<AssistantHints view={view([hint('a')])} phone />);
|
||||
const dismiss = screen.getByRole('button', { name: 'Hide this hint' });
|
||||
expect(dismiss.className).toContain('h-10');
|
||||
expect(dismiss.className).not.toContain('opacity-0');
|
||||
});
|
||||
|
||||
it('FE-AI-HINTLIST-006: in the window the X hides until hover only on a screen that can hover', () => {
|
||||
render(<AssistantHints view={view([hint('a')])} phone={false} />);
|
||||
const dismiss = screen.getByRole('button', { name: 'Hide this hint' });
|
||||
expect(dismiss.className).toContain('[@media(hover:hover)]:opacity-0');
|
||||
expect(dismiss.className.split(' ')).not.toContain('opacity-0');
|
||||
});
|
||||
|
||||
it('FE-AI-HINTLIST-004: offline the rows stay readable but do not ask', () => {
|
||||
network.offline = true;
|
||||
const one = view([hint('a')]);
|
||||
render(<AssistantHints view={one} phone={false} />);
|
||||
const row = screen.getByRole('button', { name: 'Line a' });
|
||||
expect(row).toBeDisabled();
|
||||
fireEvent.click(row);
|
||||
expect(one.ask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { BedDouble, Clock, type LucideIcon, MapPin, Ticket, TriangleAlert, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import type { HintRule } from './assistantHintRules';
|
||||
import type { AssistantHintsView, ReadyHint } from './useAssistantHints';
|
||||
|
||||
/** Rows shown before "n more", so the suggestions under them stay in view. */
|
||||
export const MAX_VISIBLE_HINTS = 3;
|
||||
|
||||
/** Warnings all carry the one triangle; a note says what it is about. */
|
||||
const NOTE_ICONS: Partial<Record<HintRule, LucideIcon>> = {
|
||||
gap: Clock,
|
||||
longLeg: MapPin,
|
||||
noStay: BedDouble,
|
||||
unplacedBooking: Ticket,
|
||||
};
|
||||
|
||||
/**
|
||||
* The plan hints in the empty chat: a short, quiet list, at most three rows
|
||||
* with the rest behind "n more". A row is the button: it puts its day or
|
||||
* places in focus and asks its question. Each can be waved away. On the
|
||||
* desktop the X shows on hover or focus, on the phone always, with room for
|
||||
* a thumb. Shown means seen, so the launcher stops pointing at them.
|
||||
*/
|
||||
export default function AssistantHints({ view, phone }: { view: AssistantHintsView; phone: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { offline } = useNetworkMode();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const shown = expanded ? view.hints : view.hints.slice(0, MAX_VISIBLE_HINTS);
|
||||
const rest = view.hints.length - shown.length;
|
||||
const shownIds = shown.map((h) => h.id).join('\n');
|
||||
const { markSeen } = view;
|
||||
|
||||
useEffect(() => {
|
||||
if (shownIds) markSeen(shownIds.split('\n'));
|
||||
}, [shownIds, markSeen]);
|
||||
|
||||
if (view.hints.length === 0) return null;
|
||||
return (
|
||||
<section aria-label={t('assistant.hint.title')} className="flex w-full max-w-sm flex-col gap-1.5">
|
||||
<h3 className="m-0 px-1 text-caption font-medium text-content-muted">{t('assistant.hint.title')}</h3>
|
||||
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
{shown.map((hint) => (
|
||||
<HintRow key={hint.id} hint={hint} phone={phone} disabled={offline} view={view} />
|
||||
))}
|
||||
</ul>
|
||||
{rest > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className={`self-start rounded-lg px-1 text-caption text-content-muted hover:text-content ${phone ? 'min-h-[44px]' : 'py-1'}`}
|
||||
>
|
||||
{t('assistant.hint.more', { count: rest })}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HintRow({
|
||||
hint,
|
||||
phone,
|
||||
disabled,
|
||||
view,
|
||||
}: {
|
||||
hint: ReadyHint;
|
||||
phone: boolean;
|
||||
disabled: boolean;
|
||||
view: AssistantHintsView;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const warn = hint.level === 'warn';
|
||||
const Icon = warn ? TriangleAlert : (NOTE_ICONS[hint.rule] ?? Clock);
|
||||
const dismissLabel = t('assistant.hint.dismiss');
|
||||
return (
|
||||
<li className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => view.ask(hint)}
|
||||
className={`flex w-full items-start gap-2 rounded-xl border border-edge-faint bg-surface-card py-2 ps-3 text-start text-body text-content transition-colors hover:border-edge hover:bg-surface-hover disabled:cursor-default disabled:opacity-60 disabled:hover:border-edge-faint disabled:hover:bg-surface-card ${
|
||||
phone ? 'min-h-[44px] pe-11' : 'pe-9'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={`mt-[3px] flex-shrink-0 ${warn ? 'text-content-secondary' : 'text-content-muted'}`}
|
||||
/>
|
||||
<span dir="auto" className="line-clamp-2 min-w-0 flex-1">
|
||||
{hint.text}
|
||||
</span>
|
||||
</button>
|
||||
{/* Hidden until hover only where there is hover: a touch screen at window size shows it always. */}
|
||||
<Tooltip label={dismissLabel}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => view.dismiss(hint.id)}
|
||||
aria-label={dismissLabel}
|
||||
className={`absolute grid place-items-center rounded-lg text-content-faint transition-opacity hover:bg-surface-hover hover:text-content ${
|
||||
phone
|
||||
? 'end-0.5 top-0.5 h-10 w-10'
|
||||
: 'end-1 top-1 h-7 w-7 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:hover)]:opacity-0'
|
||||
}`}
|
||||
>
|
||||
<X size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* FE-AI-HOST-001 to FE-AI-HOST-005
|
||||
*
|
||||
* The desktop host follows the trip on screen and sends "Show on map" and a
|
||||
* picked day to the planner through its links.
|
||||
*/
|
||||
import type { AssistantApplied } from '@trek/shared';
|
||||
import { useLocation } from 'react-router';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { act, render, screen } from '../../../tests/helpers/render';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Trip } from '../../types';
|
||||
import AssistantHost from './AssistantHost';
|
||||
|
||||
interface WindowProps {
|
||||
trip: { id: number; title: string } | null;
|
||||
onShowOnMap?: (applied: AssistantApplied) => void;
|
||||
onFocusDay?: (dayId: number) => void;
|
||||
}
|
||||
const seen: { props: WindowProps | null } = { props: null };
|
||||
|
||||
vi.mock('./AssistantWindow', () => ({
|
||||
default: (props: WindowProps) => {
|
||||
seen.props = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
function Location() {
|
||||
const { pathname, search } = useLocation();
|
||||
return <output data-testid="location">{`${pathname}${search}`}</output>;
|
||||
}
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<>
|
||||
<AssistantHost />
|
||||
<Location />
|
||||
</>,
|
||||
{ initialEntries: [path] }
|
||||
);
|
||||
}
|
||||
|
||||
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
|
||||
toolCallId: 'c1',
|
||||
entity,
|
||||
ids,
|
||||
dayId,
|
||||
undoUntil: null,
|
||||
});
|
||||
|
||||
const INITIAL = useTripStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
useTripStore.setState(INITIAL, true);
|
||||
seen.props = null;
|
||||
});
|
||||
|
||||
describe('AssistantHost', () => {
|
||||
it('FE-AI-HOST-001: outside a trip the window gets no trip and asks which one', () => {
|
||||
renderAt('/dashboard');
|
||||
expect(seen.props?.trip).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-HOST-002: inside a trip it is about that trip, named once the planner has it', () => {
|
||||
renderAt('/trips/5/files');
|
||||
expect(seen.props?.trip).toEqual({ id: 5, title: '' });
|
||||
|
||||
act(() => useTripStore.setState({ trip: { id: 5, title: 'Kyoto' } as Trip }));
|
||||
expect(seen.props?.trip).toEqual({ id: 5, title: 'Kyoto' });
|
||||
});
|
||||
|
||||
it('FE-AI-HOST-003: "Show on map" opens the planner on the place, or on the day', () => {
|
||||
renderAt('/trips/5/files');
|
||||
act(() => seen.props?.onShowOnMap?.(applied('place', [7], 2)));
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?place=7');
|
||||
|
||||
act(() => seen.props?.onShowOnMap?.(applied('day_note', [9], 3)));
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=3');
|
||||
});
|
||||
|
||||
it('FE-AI-HOST-005: a day picked in the chat selects it in the planner', () => {
|
||||
renderAt('/trips/5');
|
||||
act(() => seen.props?.onFocusDay?.(12));
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=12');
|
||||
});
|
||||
|
||||
it('FE-AI-HOST-004: a route that only looks like a trip is none', () => {
|
||||
renderAt('/trips/new');
|
||||
expect(seen.props?.trip).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { AssistantApplied } from '@trek/shared';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation, useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantWindow from './AssistantWindow';
|
||||
import { showOnMapSearch } from './assistantShowOnMap';
|
||||
|
||||
/** The trip a route under /trips/:id shows, or null anywhere else. */
|
||||
function useRouteTripId(): number | null {
|
||||
const match = useMatch('/trips/:id/*');
|
||||
const id = Number(match?.params.id);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop assistant, mounted once above the routes so a conversation
|
||||
* keeps going from page to page. Inside a trip it is about that trip; "Show
|
||||
* on map" opens the planner on the change through its links (`?place=`,
|
||||
* `?day=`), the same way a notification opens a place, and a day picked in
|
||||
* the chat (its weather) selects that day the same way.
|
||||
*/
|
||||
export default function AssistantHost() {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const tripId = useRouteTripId();
|
||||
// The title is there once the planner has loaded the trip; the window waits for nothing else.
|
||||
const title = useTripStore((s) => (s.trip && s.trip.id === tripId ? s.trip.title : ''));
|
||||
const trip = useMemo(() => (tripId === null ? null : { id: tripId, title }), [tripId, title]);
|
||||
|
||||
const openPlanner = useCallback(
|
||||
(search: string) => {
|
||||
if (tripId === null) return;
|
||||
const planner = `/trips/${tripId}`;
|
||||
void navigate({ pathname: planner, search }, { replace: pathname === planner });
|
||||
},
|
||||
[navigate, pathname, tripId]
|
||||
);
|
||||
const showOnMap = useCallback((applied: AssistantApplied) => openPlanner(showOnMapSearch(applied)), [openPlanner]);
|
||||
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
|
||||
|
||||
return <AssistantWindow trip={trip} onShowOnMap={showOnMap} onFocusDay={focusDay} />;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* FE-AI-LAUNCH-001 to FE-AI-LAUNCH-006
|
||||
*
|
||||
* The way in: hidden until the assistant can run, a button that opens and
|
||||
* closes the window, a dot for warnings in the plan not seen yet, and
|
||||
* ⌘K / Ctrl+K, which leaves a dialog alone.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantLauncher from './AssistantLauncher';
|
||||
import { isAssistantHotkey, useAssistantHotkey } from './useAssistantHotkey';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
|
||||
const STATUS = { available: true } as AiStatusResponse;
|
||||
|
||||
function press(init: KeyboardEventInit): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
window.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
afterEach(() => useAssistantStore.setState(INITIAL, true));
|
||||
|
||||
describe('AssistantLauncher', () => {
|
||||
it('FE-AI-LAUNCH-001: shows nothing until the assistant has a connection to use', () => {
|
||||
useAssistantStore.setState({ status: { ...STATUS, available: false } });
|
||||
const { container } = render(<AssistantLauncher variant="navbar" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-AI-LAUNCH-002: the navbar button and the floating one open and close the window', () => {
|
||||
useAssistantStore.setState({ status: STATUS });
|
||||
const { unmount } = render(<AssistantLauncher variant="navbar" />);
|
||||
const button = screen.getByRole('button', { name: 'Assistant' });
|
||||
fireEvent.click(button);
|
||||
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||
expect(button).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(button);
|
||||
expect(useAssistantStore.getState().window.open).toBe(false);
|
||||
unmount();
|
||||
|
||||
render(<AssistantLauncher variant="floating" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-AI-LAUNCH-006: the floating button carries a dot for a warning not seen yet, until the chat is opened', () => {
|
||||
window.localStorage.clear();
|
||||
resetAllStores();
|
||||
const places = [
|
||||
buildPlace({ id: 50, place_time: '10:00', end_time: '12:00' }),
|
||||
buildPlace({ id: 51, place_time: '11:00', end_time: '12:30' }),
|
||||
];
|
||||
const stops = places.map((place, i) =>
|
||||
buildAssignment({ id: 60 + i, day_id: 1, place_id: place.id, order_index: i, place })
|
||||
);
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: 3, start_date: '2099-06-01', end_date: '2099-06-01' }),
|
||||
days: [buildDay({ id: 1, trip_id: 3, date: '2099-06-01' })],
|
||||
places,
|
||||
assignments: { '1': stops },
|
||||
reservations: [],
|
||||
dayNotes: {},
|
||||
});
|
||||
useAssistantStore.setState({ status: STATUS });
|
||||
|
||||
const { unmount } = render(<AssistantLauncher variant="navbar" />);
|
||||
expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<AssistantLauncher variant="floating" tripId={3} />);
|
||||
const button = screen.getByRole('button', { name: 'Assistant, 1 hint' });
|
||||
// The dot stands out from the accent button it sits on.
|
||||
expect(button.querySelector('.bg-warning')).not.toBeNull();
|
||||
fireEvent.click(button);
|
||||
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||
expect(screen.getByRole('button', { name: 'Assistant' }).querySelector('.bg-warning')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shortcut', () => {
|
||||
it('FE-AI-LAUNCH-003: is ⌘K or Ctrl+K, without Alt or Shift', () => {
|
||||
const key = (init: KeyboardEventInit) => isAssistantHotkey(new KeyboardEvent('keydown', init));
|
||||
expect(key({ key: 'k', metaKey: true })).toBe(true);
|
||||
expect(key({ key: 'K', ctrlKey: true })).toBe(true);
|
||||
expect(key({ key: 'k' })).toBe(false);
|
||||
expect(key({ key: 'k', ctrlKey: true, altKey: true })).toBe(false);
|
||||
expect(key({ key: 'k', metaKey: true, shiftKey: true })).toBe(false);
|
||||
expect(key({ key: 'j', metaKey: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-LAUNCH-004: toggles and takes the key, only while enabled', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = renderHook(({ enabled }) => useAssistantHotkey(enabled, onToggle), {
|
||||
initialProps: { enabled: false },
|
||||
});
|
||||
expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(false);
|
||||
expect(onToggle).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ enabled: true });
|
||||
expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(true);
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-LAUNCH-005: leaves the key alone while a dialog is open or something else took it', () => {
|
||||
const onToggle = vi.fn();
|
||||
renderHook(() => useAssistantHotkey(true, onToggle));
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'trek-modal-backdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
press({ key: 'k', metaKey: true });
|
||||
backdrop.remove();
|
||||
|
||||
const taken = new KeyboardEvent('keydown', { key: 'k', metaKey: true, cancelable: true });
|
||||
taken.preventDefault();
|
||||
window.dispatchEvent(taken);
|
||||
|
||||
expect(onToggle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import { useAssistantHints } from './useAssistantHints';
|
||||
import { useAssistantShortcutKeys } from './useAssistantHotkey';
|
||||
|
||||
/** Where the floating button sits, from the window's end and bottom edges. The window grows out of it. */
|
||||
export const LAUNCHER_INSET = 24;
|
||||
export const LAUNCHER_SIZE = 48;
|
||||
|
||||
/**
|
||||
* The way into the assistant: a button in the navbar, and in the planner a
|
||||
* round one floating at the bottom end of the screen. Both open and close the
|
||||
* window; outside a trip it opens on the trip picker. Nothing shows until the
|
||||
* assistant has a connection this user can use: no setup prompt in its place.
|
||||
* The floating one carries a dot while the trip has a warning the user has
|
||||
* not been shown; opening the chat counts them as seen.
|
||||
*/
|
||||
export default function AssistantLauncher({
|
||||
variant,
|
||||
tripId = null,
|
||||
focusOnMount = false,
|
||||
}: {
|
||||
variant: 'navbar' | 'floating';
|
||||
/** The trip the floating button stands in, for its hint dot. */
|
||||
tripId?: number | null;
|
||||
/** Takes the focus when it appears, as when the window it stands for was just closed. */
|
||||
focusOnMount?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open && !s.window.minimized);
|
||||
const toggle = useAssistantStore((s) => s.toggle);
|
||||
// An answer still being written in a closed window: the floating button breathes until it is opened.
|
||||
const busy = useAssistantStore((s) =>
|
||||
!s.window.open || s.window.minimized ? Object.values(s.byTrip).some((c) => c?.isRunning) : false
|
||||
);
|
||||
const keys = useAssistantShortcutKeys();
|
||||
const hints = useAssistantHints(variant === 'floating' ? tripId : null, { withStays: false });
|
||||
const floating = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (focusOnMount) floating.current?.focus();
|
||||
}, [focusOnMount]);
|
||||
if (!available) return null;
|
||||
|
||||
const label = t('assistant.launcher.tooltip');
|
||||
if (variant === 'navbar') {
|
||||
return (
|
||||
<Tooltip label={label} shortcut={keys}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={label}
|
||||
aria-pressed={open}
|
||||
className={`relative hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg p-2 transition-colors sm:flex ${
|
||||
open ? 'bg-surface-selected text-content' : 'text-content-muted hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const pointed = hints.unseenWarn > 0 ? t('assistant.launcher.withHints', { count: hints.unseenWarn }) : label;
|
||||
const openChat = () => {
|
||||
if (!open) hints.markWarnsSeen();
|
||||
toggle();
|
||||
};
|
||||
return (
|
||||
<Tooltip label={pointed} shortcut={keys} placement="top">
|
||||
<button
|
||||
ref={floating}
|
||||
type="button"
|
||||
onClick={openChat}
|
||||
aria-label={pointed}
|
||||
aria-busy={busy || undefined}
|
||||
className="trek-assistant-pop fixed z-[var(--z-nav)] flex items-center justify-center rounded-full bg-accent text-accent-text shadow-elevated transition-transform hover:scale-105 active:scale-95"
|
||||
style={{
|
||||
insetInlineEnd: LAUNCHER_INSET,
|
||||
bottom: LAUNCHER_INSET,
|
||||
width: LAUNCHER_SIZE,
|
||||
height: LAUNCHER_SIZE,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
{busy && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="trek-assistant-busy absolute inset-0 rounded-full border-2 border-current opacity-40"
|
||||
/>
|
||||
)}
|
||||
<Sparkles size={20} aria-hidden="true" />
|
||||
{hints.unseenWarn > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute end-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-warning ring-2 ring-[var(--bg-elevated)]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* FE-AI-LIVE-001 to FE-AI-LIVE-004
|
||||
*
|
||||
* The running step's line: what it does, and once the stream says where it
|
||||
* looks, the place or the query, with a pin when the map can show the area.
|
||||
*/
|
||||
import type { AssistantActivity, AssistantMessage } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import { AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
const TRIP = 4;
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
|
||||
const running: AssistantMessage = {
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-21T08:00:00.000Z',
|
||||
parts: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'search_nearby_places', args: {} }],
|
||||
};
|
||||
|
||||
function seed(activity: AssistantActivity | null) {
|
||||
useAssistantStore.setState({
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages: [running], live: { activity } } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderSteps() {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: TRIP, variant: 'window' }}>
|
||||
<AssistantToolSteps steps={[{ toolCallId: 'call-1', toolName: 'search_nearby_places', running: true }]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => useAssistantStore.setState(INITIAL, true));
|
||||
|
||||
describe('AssistantLiveStep', () => {
|
||||
it('FE-AI-LIVE-001: names the place the running step searches near', () => {
|
||||
seed({
|
||||
toolCallId: 'call-1',
|
||||
label: 'Trastevere',
|
||||
area: { type: 'point', lat: 41.88, lng: 12.47, radiusM: 800 },
|
||||
});
|
||||
const { container } = renderSteps();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching near Trastevere');
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-LIVE-002: without a place it keeps the step name, and no pin', () => {
|
||||
seed({ toolCallId: 'call-1', label: null, area: null });
|
||||
const { container } = renderSteps();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching nearby');
|
||||
expect(container.querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it("FE-AI-LIVE-003: a place another step named is not this step's", () => {
|
||||
seed({ toolCallId: 'call-0', label: 'Trastevere', area: null });
|
||||
renderSteps();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching nearby');
|
||||
});
|
||||
|
||||
it('FE-AI-LIVE-004: with "map follows the assistant" off the place stays, the pin goes', () => {
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
useSettingsStore.setState({ settings: { ...settings, assistant_map_follow: false } });
|
||||
seed({
|
||||
toolCallId: 'call-1',
|
||||
label: 'Trastevere',
|
||||
area: { type: 'point', lat: 41.88, lng: 12.47, radiusM: 800 },
|
||||
});
|
||||
const { container } = renderSteps();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching near Trastevere');
|
||||
expect(container.querySelector('svg')).toBeNull();
|
||||
useSettingsStore.setState({ settings });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MapPin } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { toolStepKey } from './assistantModel';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { useAssistantLiveStep } from './useAssistantLiveStep';
|
||||
|
||||
/**
|
||||
* The running step as one quiet line: what it does, shimmering, and once it
|
||||
* names a place, where ("Searching near Trastevere"). A small pin says the
|
||||
* map is looking there too, shown only while the map follows the assistant.
|
||||
*/
|
||||
export default function AssistantLiveStep({ toolCallId, toolName }: { toolCallId: string; toolName: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const live = useAssistantLiveStep(tripId);
|
||||
const here = live?.toolCallId === toolCallId ? live : null;
|
||||
return (
|
||||
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption">
|
||||
{here?.hasArea && <MapPin size={12} aria-hidden="true" className="flex-shrink-0 text-content-faint" />}
|
||||
<span className="trek-assistant-shimmer min-w-0 truncate">{here ? here.text : t(toolStepKey(toolName))}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* FE-AI-MD-001 to FE-AI-MD-005
|
||||
*
|
||||
* An answer is untrusted text: nothing in it may load by itself, raw HTML
|
||||
* never becomes markup, and a link always shows where it goes.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantMarkdown from './AssistantMarkdown';
|
||||
|
||||
describe('AssistantMarkdown', () => {
|
||||
it('FE-AI-MD-001: renders Markdown as text, lists and emphasis', () => {
|
||||
const { container } = render(<AssistantMarkdown text={'**Kyoto** first\n\n- Kinkaku-ji\n- Ryoan-ji'} />);
|
||||
expect(container.querySelector('strong')).toHaveTextContent('Kyoto');
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-AI-MD-002: draws no image, in Markdown or as raw HTML', () => {
|
||||
const { container } = render(
|
||||
<AssistantMarkdown text={'\n\n<img src="https://evil.example/q.gif">'} />
|
||||
);
|
||||
expect(container.querySelector('img')).toBeNull();
|
||||
expect(container.querySelector('picture, video, audio, iframe')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-MD-003: leaves no javascript: link to click', () => {
|
||||
const { container } = render(<AssistantMarkdown text={'[open](javascript:alert(1))'} />);
|
||||
const link = container.querySelector('a');
|
||||
expect(link?.getAttribute('href') ?? '').not.toMatch(/^javascript:/i);
|
||||
});
|
||||
|
||||
it('FE-AI-MD-004: an outside link opens in its own tab and names its host', () => {
|
||||
render(<AssistantMarkdown text={'[Tickets](https://www.kinkaku-ji.or.jp/en/)'} />);
|
||||
const link = screen.getByRole('link', { name: /Tickets/ });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||
expect(link).toHaveTextContent('(www.kinkaku-ji.or.jp)');
|
||||
});
|
||||
|
||||
it('FE-AI-MD-005: a link into TREK stays in the app, and the writing caret is marked', () => {
|
||||
const { container } = render(<AssistantMarkdown text={'See [day 2](/trips/4)'} writing />);
|
||||
const link = screen.getByRole('link', { name: 'day 2' });
|
||||
expect(link).toHaveAttribute('href', '/trips/4');
|
||||
expect(link).not.toHaveAttribute('target');
|
||||
expect(container.firstElementChild).toHaveAttribute('data-writing', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { memo } from 'react';
|
||||
import Markdown, { type Components } from 'react-markdown';
|
||||
import { Link } from 'react-router';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { sanitizedMarkdownPlugins } from '../shared/markdownSanitize';
|
||||
|
||||
/**
|
||||
* An answer's text, as Markdown. The model writes it, and what the model reads
|
||||
* (trip notes, place descriptions, search results) can carry someone else's
|
||||
* words, so it renders like untrusted input: raw HTML is skipped and
|
||||
* sanitized, and nothing that loads on its own (an image is a tracking pixel
|
||||
* that leaks a request to any host) is drawn at all.
|
||||
*
|
||||
* A link shows where it goes: an outside one names its host beside the text
|
||||
* and opens in its own tab, a link into TREK stays in the app.
|
||||
*/
|
||||
|
||||
const DISALLOWED = ['img', 'picture', 'source', 'video', 'audio', 'iframe', 'object', 'embed'];
|
||||
|
||||
function hostOf(href: string): string | null {
|
||||
try {
|
||||
return new URL(href).host || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const components: Components = {
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith('/') && !href.startsWith('//')) return <Link to={href}>{children}</Link>;
|
||||
const host = href ? hostOf(href) : null;
|
||||
const text = typeof children === 'string' ? children : null;
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">
|
||||
{children}
|
||||
{host && text !== host && text !== href && <span className="text-content-muted"> ({host})</span>}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function AssistantMarkdown({ text, writing = false }: { text: string; writing?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
dir="auto"
|
||||
data-writing={writing || undefined}
|
||||
className="trek-assistant-md break-words text-body text-content"
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={sanitizedMarkdownPlugins}
|
||||
disallowedElements={DISALLOWED}
|
||||
skipHtml
|
||||
components={components}
|
||||
>
|
||||
{text}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// An answer re-renders with every streamed word; a block whose text did not change keeps its parse.
|
||||
export default memo(AssistantMarkdown);
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NotebookPen, Plus } from 'lucide-react';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
} from '../shared/DialogShell';
|
||||
import { TEXTAREA } from '../shared/dialogParts';
|
||||
import { MAX_MEMORY_CHARS, useTripMemory } from './useTripMemory';
|
||||
|
||||
/** What travellers most often want minded, one tap to add each. */
|
||||
const QUICK = ['vegetarian', 'kids', 'budget', 'slow', 'accessible', 'noMuseums'] as const;
|
||||
|
||||
/**
|
||||
* The trip's notes for the assistant: a few lines it follows in every
|
||||
* suggestion on this trip, written once instead of in every question. The
|
||||
* common ones are a tap away; the rest is free text.
|
||||
*/
|
||||
export default function AssistantMemoryDialog({ tripId, onClose }: { tripId: number; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const memory = useTripMemory(tripId);
|
||||
const [draft, setDraft] = useState(memory.text);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const add = (line: string) =>
|
||||
setDraft((prev) => (prev.includes(line) ? prev : `${prev.trimEnd()}${prev.trim() ? '\n' : ''}${line}`));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
const done = await memory.save(draft);
|
||||
setSaving(false);
|
||||
if (done) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
discardGuard={draft}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<NotebookPen size={20} strokeWidth={2} />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
eyebrow={t('assistant.launcher.tooltip')}
|
||||
title={t('assistant.memory.title')}
|
||||
sub={t('assistant.memory.sub')}
|
||||
subWraps
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<span className="text-caption tabular-nums text-content-faint">
|
||||
{draft.length}/{MAX_MEMORY_CHARS}
|
||||
</span>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" disabled={saving} onClick={() => void save()}>
|
||||
{t(saving ? 'common.saving' : 'common.save')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<DialogSection label={t('assistant.memory.quick')}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{QUICK.map((key, i) => {
|
||||
const line = t(`assistant.memory.quick.${key}`);
|
||||
const taken = draft.includes(line);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={taken}
|
||||
onClick={() => add(line)}
|
||||
className="trek-assistant-enter inline-flex items-center gap-1 rounded-full bg-surface-card px-2.5 py-1 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md disabled:translate-y-0 disabled:opacity-40 disabled:shadow-none"
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<Plus size={11} aria-hidden="true" />
|
||||
{line}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogSection>
|
||||
<DialogSection label={t('assistant.memory.notes')}>
|
||||
<textarea
|
||||
aria-label={t('assistant.memory.notes')}
|
||||
rows={6}
|
||||
maxLength={MAX_MEMORY_CHARS}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={t('assistant.memory.placeholder')}
|
||||
className={TEXTAREA}
|
||||
/>
|
||||
</DialogSection>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
ComposerPrimitive,
|
||||
type TriggerAdapter,
|
||||
type TriggerItem,
|
||||
unstable_useTriggerPopoverScopeContext as useTriggerScope,
|
||||
} from '@assistant-ui/react';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Ticket } from 'lucide-react';
|
||||
import { type MouseEvent, useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { MENTION_FORMATTER, MENTION_KINDS, type MentionKind } from './assistantMentionModel';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
|
||||
/** The icon of a day, place or booking in the `@` list. */
|
||||
function MentionKindIcon({ kind, size }: { kind: string; size: number }) {
|
||||
const props = { size, 'aria-hidden': true, className: 'flex-shrink-0' } as const;
|
||||
if (kind === 'day') return <CalendarDays {...props} />;
|
||||
if (kind === 'reservation') return <Ticket {...props} />;
|
||||
return <MapPin {...props} />;
|
||||
}
|
||||
|
||||
/** A row takes the click without the focus, so the caret stays in the text and typing goes on. */
|
||||
const keepFocus = (e: MouseEvent) => e.preventDefault();
|
||||
|
||||
/**
|
||||
* The list `@` opens over the composer: first the kinds (days, places,
|
||||
* bookings), and once letters follow the `@`, the entries that match across
|
||||
* all of them. The focus never leaves the text; arrows walk the list, Enter
|
||||
* or Tab picks, Escape closes it and keeps what was typed.
|
||||
*/
|
||||
export default function AssistantMentionPopover({
|
||||
adapter,
|
||||
onInserted,
|
||||
}: {
|
||||
/** Undefined while the trip's lists are not at hand; `@` then opens nothing. */
|
||||
adapter: TriggerAdapter | undefined;
|
||||
onInserted: (item: TriggerItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const phone = variant === 'fullscreen';
|
||||
return (
|
||||
<ComposerPrimitive.TriggerPopover
|
||||
char="@"
|
||||
adapter={adapter}
|
||||
aria-label={t('assistant.mention.hint')}
|
||||
className={`absolute bottom-full end-0 start-0 z-30 mb-1.5 overflow-y-auto rounded-xl border border-edge-faint bg-surface-card p-1 shadow-dropdown ${phone ? 'max-h-[40vh]' : 'max-h-56'}`}
|
||||
>
|
||||
<ComposerPrimitive.TriggerPopover.Directive formatter={MENTION_FORMATTER} onInserted={onInserted} />
|
||||
<MentionList row={phone ? 'min-h-[44px]' : 'min-h-[32px]'} />
|
||||
</ComposerPrimitive.TriggerPopover>
|
||||
);
|
||||
}
|
||||
|
||||
const ROW =
|
||||
'flex w-full items-center gap-2 rounded-lg px-2 text-start text-body text-content transition-colors hover:bg-surface-hover data-[highlighted]:bg-surface-hover';
|
||||
|
||||
function MentionList({ row }: { row: string }) {
|
||||
const { t } = useTranslation();
|
||||
const scope = useTriggerScope();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The highlighted row stays in view while the arrows walk past the list's edge.
|
||||
useEffect(() => {
|
||||
const hot = listRef.current?.querySelector<HTMLElement>('[data-highlighted]');
|
||||
hot?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [scope.highlightedIndex, scope.items, scope.activeCategoryId]);
|
||||
|
||||
if (!scope.open) return null;
|
||||
const listing = scope.isSearchMode || scope.activeCategoryId !== null;
|
||||
const active = scope.activeCategoryId ? MENTION_KINDS[scope.activeCategoryId as MentionKind] : undefined;
|
||||
const heading = active ? t(active.labelKey) : null;
|
||||
|
||||
return (
|
||||
<div ref={listRef}>
|
||||
{heading && (
|
||||
<ComposerPrimitive.TriggerPopoverBack
|
||||
onMouseDown={keepFocus}
|
||||
className="flex w-full items-center gap-1 rounded-lg px-1.5 py-1 text-caption font-semibold text-content-muted hover:text-content"
|
||||
>
|
||||
<ChevronLeft size={13} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
{heading}
|
||||
</ComposerPrimitive.TriggerPopoverBack>
|
||||
)}
|
||||
<ComposerPrimitive.TriggerPopoverCategories aria-label={t('assistant.mention.hint')}>
|
||||
{(categories) =>
|
||||
categories.map((c) => (
|
||||
<ComposerPrimitive.TriggerPopoverCategoryItem
|
||||
key={c.id}
|
||||
categoryId={c.id}
|
||||
onMouseDown={keepFocus}
|
||||
className={`${ROW} ${row}`}
|
||||
>
|
||||
<span className="flex text-content-muted">
|
||||
<MentionKindIcon kind={c.id} size={14} />
|
||||
</span>
|
||||
<span className="flex-1 truncate">{c.label}</span>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 text-content-faint rtl:-scale-x-100"
|
||||
/>
|
||||
</ComposerPrimitive.TriggerPopoverCategoryItem>
|
||||
))
|
||||
}
|
||||
</ComposerPrimitive.TriggerPopoverCategories>
|
||||
<ComposerPrimitive.TriggerPopoverItems aria-label={heading ?? t('assistant.mention.hint')}>
|
||||
{(items) =>
|
||||
items.map((item, index) => (
|
||||
<ComposerPrimitive.TriggerPopoverItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onMouseDown={keepFocus}
|
||||
className={`${ROW} ${row}`}
|
||||
>
|
||||
<span className="flex text-content-muted">
|
||||
<MentionKindIcon kind={item.type} size={14} />
|
||||
</span>
|
||||
<bdi className="min-w-0 flex-1 truncate">{item.label}</bdi>
|
||||
{item.description && (
|
||||
<span className="flex-shrink-0 text-caption text-content-faint">{item.description}</span>
|
||||
)}
|
||||
</ComposerPrimitive.TriggerPopoverItem>
|
||||
))
|
||||
}
|
||||
</ComposerPrimitive.TriggerPopoverItems>
|
||||
{listing && scope.items.length === 0 && (
|
||||
<p className="m-0 px-2 py-1.5 text-caption text-content-muted">{t('assistant.mention.none')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* FE-AI-MENTION-TEXT-001 to FE-AI-MENTION-TEXT-002
|
||||
*
|
||||
* A sent question with the days, places and bookings it named set off as
|
||||
* chips, and every other `@` left as text.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantMentionText from './AssistantMentionText';
|
||||
|
||||
describe('AssistantMentionText', () => {
|
||||
it('FE-AI-MENTION-TEXT-001: sets off each named @label and keeps the words around it', () => {
|
||||
const { container } = render(
|
||||
<p>
|
||||
<AssistantMentionText
|
||||
text="Is @Louvre open on @Arrival?"
|
||||
items={[
|
||||
{ kind: 'place', id: 21, label: 'Louvre' },
|
||||
{ kind: 'day', id: 11, label: 'Arrival' },
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
expect(container.querySelector('p')?.textContent).toBe('Is @Louvre open on @Arrival?');
|
||||
expect(screen.getByText('@Louvre').tagName).toBe('BDI');
|
||||
expect(screen.getByText('@Arrival').tagName).toBe('BDI');
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-TEXT-002: an @ that names nothing stays plain text', () => {
|
||||
const { container } = render(
|
||||
<p>
|
||||
<AssistantMentionText
|
||||
text="Write to me @ home or @Louvre2"
|
||||
items={[{ kind: 'place', id: 21, label: 'Louvre' }]}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
expect(container.querySelector('p')?.textContent).toBe('Write to me @ home or @Louvre2');
|
||||
expect(container.querySelector('bdi')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { AssistantMentions } from '@trek/shared';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { splitMentions } from './assistantMentionModel';
|
||||
|
||||
/**
|
||||
* A sent question as the user wrote it, with every `@label` it named a day,
|
||||
* place or booking with set off as a quiet inline chip. The labels come from
|
||||
* the question's own mentions part, so a stray `@` in the text stays text.
|
||||
*/
|
||||
export default function AssistantMentionText({ text, items }: { text: string; items: AssistantMentions['items'] }) {
|
||||
const segments = useMemo(
|
||||
() =>
|
||||
splitMentions(
|
||||
text,
|
||||
items.map((i) => i.label)
|
||||
),
|
||||
[text, items]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{segments.map((s) =>
|
||||
s.mention ? (
|
||||
<bdi key={s.at} className="rounded-md bg-surface-card px-1 font-medium text-content">
|
||||
{s.text}
|
||||
</bdi>
|
||||
) : (
|
||||
<span key={s.at}>{s.text}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
|
||||
import type { AssistantMessage as StoredMessage } from '@trek/shared';
|
||||
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantChangeCard from './AssistantChangeCard';
|
||||
import AssistantFollowUps from './AssistantFollowUps';
|
||||
import AssistantMarkdown from './AssistantMarkdown';
|
||||
import AssistantMentionText from './AssistantMentionText';
|
||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||
import AssistantShareDialog from './AssistantShareDialog';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { useAssistantShare } from './useAssistantShare';
|
||||
|
||||
/**
|
||||
* One message of the chat. The user's question sits on the end side in a
|
||||
* quiet accent bubble; the answer runs as plain text across the column, with
|
||||
* its looked-up steps, the places and weather they found, and the proposed
|
||||
* changes in between, and a copy and a share button that show on hover
|
||||
* (always on the latest answer, and always on the phone, which has no hover).
|
||||
*/
|
||||
|
||||
const BAR_BUTTON =
|
||||
'rounded-md p-1 text-content-faint hover:bg-surface-hover hover:text-content-secondary disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent';
|
||||
|
||||
/** Posts the answer to the trip chat after a preview; a tick once it went. */
|
||||
function ShareButton({ share }: { share: ReturnType<typeof useAssistantShare> }) {
|
||||
const { t } = useTranslation();
|
||||
if (share.shared) {
|
||||
return (
|
||||
<Tooltip label={t('assistant.share.shared')}>
|
||||
<span className="inline-flex p-1 text-content-faint">
|
||||
<Check size={14} aria-hidden="true" />
|
||||
<span className="sr-only">{t('assistant.share.shared')}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const label = share.offline ? t('assistant.error.offline') : t('assistant.share.button');
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
{/* The wrapper carries the tooltip while the button is off. */}
|
||||
<span className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={share.open}
|
||||
disabled={share.offline || share.sending}
|
||||
aria-label={t('assistant.share.button')}
|
||||
className={BAR_BUTTON}
|
||||
>
|
||||
{share.sending ? (
|
||||
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
|
||||
) : (
|
||||
<Share2 size={14} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A file the user handed over, as a chip with what it is and how big. */
|
||||
function AttachmentChip({ name, size }: { name: string; size: number }) {
|
||||
const kb = Math.max(1, Math.round(size / 1024));
|
||||
return (
|
||||
<span className="inline-flex max-w-full items-center gap-2.5 rounded-xl border border-edge-faint bg-surface-card py-2 pe-3.5 ps-2 shadow-card">
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-content-secondary">
|
||||
<FileText size={16} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<bdi className="truncate text-body font-medium text-content">{name}</bdi>
|
||||
<span className="text-caption tabular-nums text-content-muted">
|
||||
{kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb} KB`}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Question({ message }: { message: StoredMessage }) {
|
||||
const text = message.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
|
||||
const files = message.parts.flatMap((p) => (p.type === 'attachment' ? [p] : []));
|
||||
const mentions = message.parts.flatMap((p) => (p.type === 'data-trek-mentions' ? p.data.items : []));
|
||||
return (
|
||||
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col items-end gap-1.5 py-1.5">
|
||||
{files.map((f, i) => (
|
||||
<AttachmentChip key={`${f.name}-${i}`} name={f.name} size={f.size} />
|
||||
))}
|
||||
{text && (
|
||||
<p
|
||||
dir="auto"
|
||||
className="m-0 max-w-[85%] whitespace-pre-wrap break-words rounded-xl bg-accent-subtle px-3.5 py-2 text-body text-content"
|
||||
>
|
||||
{mentions.length > 0 ? <AssistantMentionText text={text} items={mentions} /> : text}
|
||||
</p>
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Answer({ message }: { message: StoredMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
const writing = useAssistantStore((s) => {
|
||||
const chat = chatOf(s, tripId);
|
||||
return chat.isRunning && chat.messages[chat.messages.length - 1]?.id === message.id;
|
||||
});
|
||||
const blocks = useMemo(() => messageBlocks(message, writing), [message, writing]);
|
||||
const share = useAssistantShare(tripId, message, writing);
|
||||
const last = blocks[blocks.length - 1];
|
||||
const hasText = blocks.some((b) => b.kind === 'text');
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
|
||||
{blocks.map((block) => {
|
||||
if (block.kind === 'text') {
|
||||
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
|
||||
}
|
||||
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
|
||||
if (block.kind === 'places') {
|
||||
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||
}
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||
})}
|
||||
{/* Nothing below says the answer goes on: no words being written, no step running. */}
|
||||
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
|
||||
<p role="status" className="m-0 text-caption">
|
||||
<span className="trek-assistant-shimmer">{t('assistant.step.working')}</span>
|
||||
</p>
|
||||
)}
|
||||
{hasText && !writing && (
|
||||
// The bar mounts on hover for older answers; the row keeps its height so nothing jumps.
|
||||
<div className="-mt-1 h-6">
|
||||
<ActionBarPrimitive.Root autohide={variant === 'fullscreen' ? 'never' : 'not-last'} className="-ms-1 flex">
|
||||
<ActionBarPrimitive.Copy aria-label={t('common.copy')} className={BAR_BUTTON}>
|
||||
<AuiIf condition={(s) => s.message.isCopied}>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</AuiIf>
|
||||
<AuiIf condition={(s) => !s.message.isCopied}>
|
||||
<Copy size={14} aria-hidden="true" />
|
||||
</AuiIf>
|
||||
</ActionBarPrimitive.Copy>
|
||||
{share.canShare && <ShareButton share={share} />}
|
||||
</ActionBarPrimitive.Root>
|
||||
</div>
|
||||
)}
|
||||
{share.canShare && <AssistantShareDialog share={share} />}
|
||||
<AssistantFollowUps message={message} />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantMessage({ message }: { message: StoredMessage }) {
|
||||
return message.role === 'user' ? <Question message={message} /> : <Answer message={message} />;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { AssistantFoundPlace } from '@trek/shared';
|
||||
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { createElement, type ReactNode, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { getCategoryIcon } from '../shared/categoryIcons';
|
||||
import { PopoverListPanel } from '../shared/dialogParts';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { usePopoverList } from '../shared/usePopoverList';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
||||
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
|
||||
|
||||
type Strip = ReturnType<typeof useAssistantPlaceStrip>;
|
||||
|
||||
const ACTION =
|
||||
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
|
||||
|
||||
/** "+ Day 3" for the day in focus, a list of the trip's days beside it; "Pick a day" when no day is in focus. */
|
||||
function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { days, focusDay, disabled, blockedKey, adding } = strip;
|
||||
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
|
||||
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
|
||||
});
|
||||
const { open, toggle, close, triggerRef, listId } = list;
|
||||
const height = tall ? 'h-10' : 'h-7';
|
||||
const busy = adding === place.ref;
|
||||
|
||||
const pick = (dayId: number) => {
|
||||
close();
|
||||
void strip.suggest(place, dayId);
|
||||
};
|
||||
|
||||
const buttons = (
|
||||
<span className="inline-flex min-w-0 gap-px">
|
||||
{focusDay ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void strip.suggest(place, focusDay.id)}
|
||||
aria-label={t('assistant.place.addTo', { day: focusDay.label })}
|
||||
className={`${ACTION} ${height} rounded-e-none px-2`}
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle size={12} aria-hidden="true" className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
)}
|
||||
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
|
||||
</button>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
aria-label={t('assistant.place.pickDay')}
|
||||
className={`${ACTION} ${height} rounded-s-none px-1.5`}
|
||||
>
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
className={`${ACTION} ${height} px-2`}
|
||||
>
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
<span className="truncate">{t('assistant.place.pickDay')}</span>
|
||||
<ChevronDown size={12} aria-hidden="true" className="text-content-faint" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{blockedKey ? (
|
||||
<Tooltip label={t(blockedKey)} placement="top">
|
||||
{/* A disabled button gets no pointer events; the wrapper carries the reason. */}
|
||||
<span className="inline-flex min-w-0">{buttons}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
buttons
|
||||
)}
|
||||
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
|
||||
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day) => {
|
||||
const current = day.id === focusDay?.id;
|
||||
return (
|
||||
<Tooltip key={day.id} label={day.label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(day.id)}
|
||||
aria-label={day.label}
|
||||
aria-pressed={current}
|
||||
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="text-body font-semibold tabular-nums">{day.n}</span>
|
||||
{day.weekday && (
|
||||
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
|
||||
{day.weekday}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverListPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Category = ReturnType<Strip['categoryOf']>;
|
||||
|
||||
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
|
||||
function PlaceMedia({
|
||||
place,
|
||||
photo,
|
||||
category,
|
||||
}: {
|
||||
place: AssistantFoundPlace;
|
||||
photo: string | null;
|
||||
category: Category;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const icon = category ? getCategoryIcon(category.icon) : MapPin;
|
||||
return (
|
||||
<span
|
||||
className="relative block h-24 w-full overflow-hidden bg-surface-secondary"
|
||||
// Without a picture the category's own colour washes the space, data from the trip.
|
||||
style={
|
||||
photo || !category
|
||||
? undefined
|
||||
: { background: `color-mix(in srgb, ${category.color} 16%, var(--bg-secondary))` }
|
||||
}
|
||||
>
|
||||
{photo ? (
|
||||
<img
|
||||
src={photo}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover/card:scale-[1.04]"
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-full w-full place-items-center">
|
||||
{createElement(icon, {
|
||||
size: 30,
|
||||
'aria-hidden': true,
|
||||
style: { color: category?.color ?? 'var(--text-faint)' },
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{category && photo && (
|
||||
<span className="absolute start-2 top-2 inline-flex max-w-[75%] items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content shadow-sm">
|
||||
{createElement(icon, {
|
||||
size: 11,
|
||||
'aria-hidden': true,
|
||||
className: 'flex-shrink-0',
|
||||
style: { color: category.color },
|
||||
})}
|
||||
<bdi className="truncate">{category.name}</bdi>
|
||||
</span>
|
||||
)}
|
||||
{place.openNow !== null && (
|
||||
<span className="absolute bottom-2 end-2 inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content-muted shadow-sm">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-1.5 w-1.5 rounded-full ${place.openNow ? 'bg-success' : 'bg-[color:var(--text-faint)]'}`}
|
||||
/>
|
||||
{t(place.openNow ? 'assistant.place.openNow' : 'assistant.place.closed')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's one way on: "On map" for a place the trip has, "Suggested" once it is, else "+ Day 3". */
|
||||
function PlaceAction({
|
||||
place,
|
||||
strip,
|
||||
suggested,
|
||||
tall,
|
||||
}: {
|
||||
place: AssistantFoundPlace;
|
||||
strip: Strip;
|
||||
suggested: boolean;
|
||||
tall: boolean;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
if (place.inTripPlaceId !== null) {
|
||||
return (
|
||||
<Tooltip label={t('assistant.place.inTrip')} placement="top">
|
||||
<button type="button" onClick={() => strip.show(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
|
||||
<MapPin size={12} aria-hidden="true" />
|
||||
{t('assistant.place.showOnMap')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (suggested) {
|
||||
return (
|
||||
<span className="inline-flex h-7 items-center gap-1 text-caption text-content-muted">
|
||||
<Check size={12} aria-hidden="true" className="text-success" />
|
||||
{t('assistant.place.suggested')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One place a search found, as a card: its picture from TREK's photo proxy
|
||||
* with the category it most likely belongs in on it (or that category's icon
|
||||
* on its colour), its name, address and rating, whether it is open now while
|
||||
* the trip runs, how far it is from the last stop of the day in focus, and one
|
||||
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
|
||||
* the card shows it on the map, and resting the pointer on it pins it there
|
||||
* for a moment.
|
||||
*/
|
||||
export default function AssistantPlaceCard({
|
||||
place,
|
||||
strip,
|
||||
suggested,
|
||||
}: {
|
||||
place: AssistantFoundPlace;
|
||||
strip: Strip;
|
||||
suggested: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { variant, density } = useAssistantView();
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
const photo = useFoundPlacePhoto(place, ref);
|
||||
const category = strip.categoryOf(place);
|
||||
const distance = strip.distanceOf(place);
|
||||
const tall = variant === 'fullscreen';
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
className={`group/card flex flex-shrink-0 snap-start flex-col overflow-hidden rounded-2xl border border-edge-faint bg-surface-card shadow-sm transition-shadow hover:shadow-md ${
|
||||
density === 'compact' ? 'w-[188px]' : 'w-[220px]'
|
||||
}`}
|
||||
onMouseEnter={() => strip.hover(place)}
|
||||
onMouseLeave={() => strip.hover(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => strip.show(place)}
|
||||
onFocus={() => strip.hover(place)}
|
||||
onBlur={() => strip.hover(null)}
|
||||
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
|
||||
>
|
||||
<PlaceMedia place={place} photo={photo} category={category} />
|
||||
<span className="flex min-w-0 flex-col gap-1 px-3 pt-2.5">
|
||||
<bdi className="line-clamp-2 text-body font-semibold leading-snug text-content">{place.name}</bdi>
|
||||
{category && !photo && <bdi className="truncate text-caption text-content-muted">{category.name}</bdi>}
|
||||
{place.address && <bdi className="truncate text-caption text-content-muted">{place.address}</bdi>}
|
||||
{(place.rating !== null || distance) && (
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-content-faint">
|
||||
{place.rating !== null && (
|
||||
<span className="inline-flex flex-shrink-0 items-center gap-0.5 font-medium text-content-muted">
|
||||
<Star size={11} aria-hidden="true" className="fill-current text-warning" />
|
||||
{place.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{distance && <span className="truncate">{t('assistant.place.fromPrev', { distance })}</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
|
||||
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* FE-AI-PLACES-001 to FE-AI-PLACES-008
|
||||
*
|
||||
* The strip of places a search found: what a card says, that its picture only
|
||||
* ever comes from TREK's photo proxy, "+ Day N" for the day in focus or a
|
||||
* picked one, why it waits while a change is undecided, and the map's part.
|
||||
*/
|
||||
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAssignment,
|
||||
buildCategory,
|
||||
buildDay,
|
||||
buildPlace,
|
||||
buildTrip,
|
||||
buildUser,
|
||||
} from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { fetchPhoto } from '../../services/photoService';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { setForcedOffline } from '../../sync/networkMode';
|
||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
fetchPhoto: vi.fn(),
|
||||
getCached: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
const TRIP = 4;
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
const TRIPS = useTripStore.getState();
|
||||
const AUTH = useAuthStore.getState();
|
||||
const RealObserver = globalThis.IntersectionObserver;
|
||||
|
||||
const DAY_ONE = buildDay({ id: 31, date: '2026-11-02' });
|
||||
const DAY_TWO = buildDay({ id: 32, date: '2026-11-03' });
|
||||
|
||||
function place(ref: string, extra: Partial<AssistantFoundPlace> = {}): AssistantFoundPlace {
|
||||
return {
|
||||
ref,
|
||||
name: `Trattoria ${ref}`,
|
||||
address: null,
|
||||
lat: 41.889,
|
||||
lng: 12.47,
|
||||
kind: 'food',
|
||||
rating: null,
|
||||
openNow: null,
|
||||
googlePlaceId: null,
|
||||
osmId: null,
|
||||
photoKey: null,
|
||||
inTripPlaceId: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const answer = (parts: AssistantMessage['parts'] = []): AssistantMessage => ({
|
||||
id: 'msg-5',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-21T08:00:00.000Z',
|
||||
parts,
|
||||
});
|
||||
|
||||
const addFoundPlace = vi.fn(async () => {});
|
||||
const requestCamera = vi.fn();
|
||||
const setPreviewPin = vi.fn();
|
||||
const onRevealMap = vi.fn();
|
||||
|
||||
function seed({ focusDayId = 31, messages = [] as AssistantMessage[] } = {}) {
|
||||
useAuthStore.setState({ user: buildUser({ id: 1 }), placesPhotosEnabled: true });
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: TRIP, user_id: 1 }),
|
||||
days: [DAY_ONE, DAY_TWO],
|
||||
assignments: {
|
||||
'31': [buildAssignment({ order_index: 0, place: buildPlace({ lat: 41.9, lng: 12.47 }) })],
|
||||
},
|
||||
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
|
||||
});
|
||||
useAssistantStore.setState({
|
||||
addFoundPlace,
|
||||
requestCamera,
|
||||
setPreviewPin,
|
||||
byTrip: {
|
||||
[TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7, focus: { dayId: focusDayId }, messages },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderStrip(found: AssistantPlaces, message = answer(), view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: TRIP, variant: 'window', density: 'compact', onRevealMap, ...view };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={value}>
|
||||
<AssistantPlaceStrip found={found} message={message} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => setForcedOffline(false));
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
useTripStore.setState(TRIPS, true);
|
||||
useAuthStore.setState(AUTH, true);
|
||||
globalThis.IntersectionObserver = RealObserver;
|
||||
setForcedOffline(false);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AssistantPlaceStrip', () => {
|
||||
it('FE-AI-PLACES-001: a card names the place, its likely category, whether it is open and how far it is', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0', { openNow: true })], more: 0 });
|
||||
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
|
||||
expect(screen.getByText('Restaurant')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open now')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.2 km from the previous stop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-002: shows every card it was sent and counts what the server left out on a tile', () => {
|
||||
seed();
|
||||
const list = Array.from({ length: 8 }, (_, i) => place(`s1:${i}`));
|
||||
renderStrip({ toolCallId: 's1', places: list, more: 4 });
|
||||
expect(screen.getAllByRole('button', { name: /^Add to/ })).toHaveLength(8);
|
||||
expect(screen.getByText('4 more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-009: a place known only by its position shows its category, never a photo of the area', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0', { photoKey: 'coords:41.889000,12.470000' })], more: 0 });
|
||||
expect(fetchPhoto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-003: a picture only ever comes from the photo proxy', async () => {
|
||||
seed();
|
||||
globalThis.IntersectionObserver = class {
|
||||
private readonly callback: IntersectionObserverCallback;
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
observe() {
|
||||
this.callback([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
|
||||
}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
vi.mocked(fetchPhoto).mockImplementation((key, _id, _lat, _lng, _name, callback) => {
|
||||
const photoUrl = key === 'good' ? '/api/maps/place-photo/good/bytes' : 'https://evil.example/x.jpg';
|
||||
callback?.({ photoUrl, thumbDataUrl: null });
|
||||
});
|
||||
const { container } = renderStrip({
|
||||
toolCallId: 's1',
|
||||
places: [place('s1:0', { photoKey: 'good' }), place('s1:1', { photoKey: 'bad', lat: 40 })],
|
||||
more: 0,
|
||||
});
|
||||
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1));
|
||||
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/maps/place-photo/good/bytes');
|
||||
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-004: "+ Day 1" suggests the place for the day in focus', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add to/ }));
|
||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 31);
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-005: another day is picked from the list, and without a focus the list is the only way', () => {
|
||||
seed({ focusDayId: 0 });
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Pick a day/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Nov 3/ }));
|
||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 32);
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-006: waits while a change is undecided, and offline', () => {
|
||||
const waiting = answer([
|
||||
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
|
||||
]);
|
||||
seed({ messages: [waiting] });
|
||||
const { unmount } = renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
seed();
|
||||
setForcedOffline(true);
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
|
||||
seed();
|
||||
const suggested = answer([
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'f1',
|
||||
toolName: 'add_found_place',
|
||||
args: { lat: 41.889, lng: 12.47 },
|
||||
approval: { id: 'f1', approved: true },
|
||||
},
|
||||
]);
|
||||
renderStrip(
|
||||
{ toolCallId: 's1', places: [place('s1:0'), place('s1:1', { lat: 40, inTripPlaceId: 8 })], more: 0 },
|
||||
suggested,
|
||||
{ variant: 'fullscreen' }
|
||||
);
|
||||
expect(screen.getByText('Suggested')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'On map' }));
|
||||
expect(onRevealMap).toHaveBeenCalled();
|
||||
expect(requestCamera).toHaveBeenCalledWith(TRIP, [[40, 12.47]], 'pan', 'user');
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-008: resting on a card pins it on the map until the pointer leaves', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
const card = screen.getByText('Trattoria s1:0').closest('li');
|
||||
if (!card) throw new Error('no card');
|
||||
fireEvent.mouseEnter(card);
|
||||
expect(setPreviewPin).toHaveBeenLastCalledWith({
|
||||
tripId: TRIP,
|
||||
ref: 's1:0',
|
||||
lat: 41.889,
|
||||
lng: 12.47,
|
||||
name: 'Trattoria s1:0',
|
||||
});
|
||||
fireEvent.mouseLeave(card);
|
||||
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { AssistantMessage, AssistantPlaces } from '@trek/shared';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import AssistantPlaceCard from './AssistantPlaceCard';
|
||||
import { suggestedFromStrip } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
||||
|
||||
/** Cards the strip shows, every one the server sends; what it left out is counted on the tile at its end. */
|
||||
const SHOWN = 60;
|
||||
|
||||
// In the strip's head, never over a card: a press that ends a pixel off still lands on the arrow.
|
||||
const ARROW =
|
||||
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content disabled:cursor-default disabled:opacity-40 disabled:hover:text-content-secondary';
|
||||
|
||||
/** How far the strip has scrolled from either end, with the reading direction in mind. */
|
||||
function edgesOf(el: HTMLElement): { start: boolean; end: boolean } {
|
||||
// A right-to-left strip scrolls into negative numbers; the distance from the start is the same either way.
|
||||
const from = Math.abs(el.scrollLeft);
|
||||
return { start: from > 4, end: from + el.clientWidth < el.scrollWidth - 4 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The places a search found, as one row of cards to swipe or scroll through:
|
||||
* the next card peeks in, so the row says it goes on, and the edges fade
|
||||
* where there is more. On the desktop a head over the row names it and holds
|
||||
* two arrows that move it by whole cards, off while that way is the end. Every card the step found, then a
|
||||
* tile counting what the server left out.
|
||||
*/
|
||||
export default function AssistantPlaceStrip({ found, message }: { found: AssistantPlaces; message: AssistantMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const strip = useAssistantPlaceStrip();
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const [fade, setFade] = useState({ start: false, end: false });
|
||||
const rest = Math.max(0, found.places.length - SHOWN) + found.more;
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
const next = edgesOf(el);
|
||||
setFade((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
const el = listRef.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [measure, found]);
|
||||
|
||||
// The pin a card left on the map goes with the strip.
|
||||
const { hover } = strip;
|
||||
useEffect(() => () => hover(null), [hover]);
|
||||
|
||||
/**
|
||||
* Scrolls by as many whole cards as the view holds, towards the start (-1)
|
||||
* or the end (1), so the row lands on a card edge instead of fighting its
|
||||
* snap points half way; smoothly, unless motion is turned down.
|
||||
*/
|
||||
const page = (towards: -1 | 1) => {
|
||||
const el = listRef.current;
|
||||
const card = el?.firstElementChild as HTMLElement | null;
|
||||
if (!el || !card) return;
|
||||
const gap = Number.parseFloat(getComputedStyle(el).columnGap) || 0;
|
||||
const step = card.offsetWidth + gap;
|
||||
const cards = Math.max(1, Math.floor((el.clientWidth - 32) / step));
|
||||
const rtl = getComputedStyle(el).direction === 'rtl';
|
||||
// Smooth through the strip's own scroll-behavior, which reduced motion turns off.
|
||||
el.scrollBy((rtl ? -towards : towards) * cards * step, 0);
|
||||
};
|
||||
|
||||
const total = found.places.length + found.more;
|
||||
return (
|
||||
<section aria-label={t('assistant.place.strip')} className="-mx-4">
|
||||
{variant === 'window' && (fade.start || fade.end) && (
|
||||
// The cards are reachable by Tab; the arrows are for the pointer only.
|
||||
<div className="mb-1.5 flex items-center gap-2 px-4">
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-muted">
|
||||
{t('assistant.place.strip')}
|
||||
<span className="ms-1.5 tabular-nums text-content-faint">{total}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disabled={!fade.start}
|
||||
onClick={() => page(-1)}
|
||||
className={ARROW}
|
||||
>
|
||||
<ChevronLeft size={15} className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disabled={!fade.end}
|
||||
onClick={() => page(1)}
|
||||
className={ARROW}
|
||||
>
|
||||
<ChevronRight size={15} className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ul
|
||||
ref={listRef}
|
||||
onScroll={measure}
|
||||
data-fade-start={fade.start || undefined}
|
||||
data-fade-end={fade.end || undefined}
|
||||
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5 motion-safe:scroll-smooth"
|
||||
>
|
||||
{found.places.slice(0, SHOWN).map((place) => (
|
||||
<AssistantPlaceCard
|
||||
key={place.ref}
|
||||
place={place}
|
||||
strip={strip}
|
||||
suggested={suggestedFromStrip(message, place)}
|
||||
/>
|
||||
))}
|
||||
{rest > 0 && (
|
||||
<li className="flex w-24 flex-shrink-0 snap-start items-center justify-center rounded-2xl border border-dashed border-edge-faint px-2 text-center text-caption text-content-muted">
|
||||
{t('assistant.place.more', { count: rest })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* FE-AI-POWER-001 to FE-AI-POWER-010
|
||||
*
|
||||
* The parts that tie the chat into the planner: the next questions under an
|
||||
* answer, the waiting changes inside the day list, the week grid of a plan
|
||||
* over several days, the context menu rows, the trip notes and the part of the
|
||||
* map a question carries.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { AssistantChangePreview, AssistantMessage } from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { mapViewOf } from '../Map/assistantViewport';
|
||||
import AssistantDayPreview from './AssistantDayPreview';
|
||||
import { followUpsFor } from './assistantFollowUpRules';
|
||||
import AssistantFollowUps from './AssistantFollowUps';
|
||||
import type { ChangeItem } from './assistantParts';
|
||||
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||
import AssistantWeekOverview from './AssistantWeekOverview';
|
||||
import { useAssistantMenu } from './useAssistantMenu';
|
||||
import { useTripMemory } from './useTripMemory';
|
||||
import { useWeekDays } from './useWeekDays';
|
||||
|
||||
const TRIP = 9;
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
const TRIPS = useTripStore.getState();
|
||||
const SETTINGS = useSettingsStore.getState();
|
||||
const AUTH = useAuthStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
useTripStore.setState(TRIPS, true);
|
||||
useSettingsStore.setState(SETTINGS, true);
|
||||
useAuthStore.setState(AUTH, true);
|
||||
});
|
||||
|
||||
const view: AssistantView = { tripId: TRIP, variant: 'window' };
|
||||
const inView = (node: ReactNode) => <AssistantViewContext.Provider value={view}>{node}</AssistantViewContext.Provider>;
|
||||
|
||||
function answer(parts: AssistantMessage['parts'], id = 'a1'): AssistantMessage {
|
||||
return { id, role: 'assistant', status: 'complete', createdAt: '2026-10-21T08:00:00.000Z', parts };
|
||||
}
|
||||
|
||||
function preview(
|
||||
toolCallId: string,
|
||||
dayId: number,
|
||||
over: Partial<AssistantChangePreview> = {}
|
||||
): AssistantChangePreview {
|
||||
return {
|
||||
toolCallId,
|
||||
verbKey: 'addPlace',
|
||||
subject: 'Place',
|
||||
rows: [],
|
||||
dayId,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
effect: { kind: 'add', dayId, fromDayId: null, placeId: null, assignmentId: null, place: null },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function waiting(toolCallId: string, data: AssistantChangePreview): AssistantMessage['parts'] {
|
||||
return [
|
||||
{ type: 'tool-call', toolCallId, toolName: 'create_place', args: {}, approval: { id: toolCallId } },
|
||||
{ type: 'data-trek-change', data },
|
||||
];
|
||||
}
|
||||
|
||||
function seedChat(messages: AssistantMessage[], extra: Record<string, unknown> = {}) {
|
||||
useAssistantStore.setState({
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages, ...extra } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('the next questions', () => {
|
||||
it('FE-AI-POWER-001: an answer offers what fits what it holds, changes only to someone who may make them', () => {
|
||||
const ids = (parts: AssistantMessage['parts'], canEdit = true) =>
|
||||
followUpsFor(answer(parts), canEdit).map((f) => f.id);
|
||||
expect(ids([{ type: 'data-trek-places', data: { toolCallId: 's', places: [], more: 0 } }])).toContain('cheaper');
|
||||
expect(ids([{ type: 'data-trek-change', data: preview('c', 1) }])).toContain('nextDay');
|
||||
expect(ids([{ type: 'data-trek-change', data: preview('c', 1) }], false)).toEqual([]);
|
||||
expect(ids([{ type: 'text', text: 'Hi' }])).toEqual(['more', 'next']);
|
||||
expect(ids([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-POWER-002: under the last finished answer a tap sends the question; not while it runs', () => {
|
||||
const send = vi.fn(async () => {});
|
||||
const last = answer([{ type: 'text', text: 'Done.' }]);
|
||||
seedChat([last]);
|
||||
useAssistantStore.setState({ send });
|
||||
const { rerender } = render(inView(<AssistantFollowUps message={last} />));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tell me more' }));
|
||||
expect(send).toHaveBeenCalledWith(TRIP, 'Tell me more');
|
||||
|
||||
seedChat([last], { isRunning: true });
|
||||
rerender(inView(<AssistantFollowUps message={last} />));
|
||||
expect(screen.queryByRole('button', { name: 'Tell me more' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the day list preview', () => {
|
||||
it('FE-AI-POWER-003: a waiting add shows dashed on its day, a removal struck, and resting on it lights the change', () => {
|
||||
useTripStore.setState({ places: [buildPlace({ id: 5, name: 'Louvre' })] });
|
||||
const add = preview('c1', 3, {
|
||||
effect: {
|
||||
kind: 'add',
|
||||
dayId: 3,
|
||||
fromDayId: null,
|
||||
placeId: null,
|
||||
assignmentId: null,
|
||||
place: { name: 'Pantheon', lat: 1, lng: 2, categoryId: null },
|
||||
},
|
||||
});
|
||||
const remove = preview('c2', 3, {
|
||||
effect: { kind: 'remove', dayId: 3, fromDayId: null, placeId: 5, assignmentId: 50, place: null },
|
||||
});
|
||||
seedChat([answer([...waiting('c1', add), ...waiting('c2', remove)])]);
|
||||
render(<AssistantDayPreview tripId={TRIP} dayId={3} />);
|
||||
expect(screen.getByText('Pantheon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Louvre')).toHaveClass('line-through');
|
||||
fireEvent.mouseEnter(screen.getByText('Pantheon'));
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBe('c1');
|
||||
});
|
||||
|
||||
it('FE-AI-POWER-004: nothing shows on another day, or once the change was decided', () => {
|
||||
const add = preview('c1', 3, {
|
||||
effect: {
|
||||
kind: 'add',
|
||||
dayId: 3,
|
||||
fromDayId: null,
|
||||
placeId: null,
|
||||
assignmentId: null,
|
||||
place: { name: 'Pantheon', lat: 1, lng: 2, categoryId: null },
|
||||
},
|
||||
});
|
||||
seedChat([answer(waiting('c1', add))]);
|
||||
const { container } = render(<AssistantDayPreview tripId={TRIP} dayId={4} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the week grid', () => {
|
||||
const item = (id: string, dayId: number): ChangeItem => ({
|
||||
toolCallId: id,
|
||||
preview: preview(id, dayId),
|
||||
applied: null,
|
||||
state: 'pending',
|
||||
});
|
||||
|
||||
it('FE-AI-POWER-005: from three days on the changes group by day, in trip order', () => {
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: TRIP }),
|
||||
days: [
|
||||
buildDay({ id: 1, date: '2026-11-02' }),
|
||||
buildDay({ id: 2, date: '2026-11-03' }),
|
||||
buildDay({ id: 3, date: '2026-11-04' }),
|
||||
],
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => inView(children);
|
||||
const three = renderHook(() => useWeekDays([item('a', 3), item('b', 1), item('c', 2), item('d', 1)]), { wrapper });
|
||||
expect(three.result.current.map((d) => [d.n, d.ids])).toEqual([
|
||||
[1, ['b', 'd']],
|
||||
[2, ['c']],
|
||||
[3, ['a']],
|
||||
]);
|
||||
const two = renderHook(() => useWeekDays([item('a', 1), item('b', 2)]), { wrapper });
|
||||
expect(two.result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-POWER-006: a tap leaves a whole day out or takes it back in', () => {
|
||||
const onToggleDay = vi.fn();
|
||||
const days = [
|
||||
{ id: 1, n: 1, weekday: 'Mon', ids: ['a', 'b'] },
|
||||
{ id: 2, n: 2, weekday: 'Tue', ids: ['c'] },
|
||||
];
|
||||
render(<AssistantWeekOverview days={days} excluded={new Set(['c'])} disabled={false} onToggleDay={onToggleDay} />);
|
||||
const [mon, tue] = screen.getAllByRole('button');
|
||||
expect(mon).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(tue).toHaveAttribute('aria-pressed', 'false');
|
||||
fireEvent.click(mon);
|
||||
expect(onToggleDay).toHaveBeenCalledWith(['a', 'b'], false);
|
||||
fireEvent.click(tue);
|
||||
expect(onToggleDay).toHaveBeenLastCalledWith(['c'], true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the context menu rows', () => {
|
||||
it('FE-AI-POWER-007: none while the assistant cannot run; else they open the chat on the place and ask', () => {
|
||||
const send = vi.fn(async () => {});
|
||||
const openWith = vi.fn();
|
||||
useAssistantStore.setState({ status: null, send, openWith });
|
||||
const louvre = buildPlace({ id: 5, name: 'Louvre' });
|
||||
const translated = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
|
||||
const off = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
|
||||
expect(off.result.current.place(louvre)).toEqual([]);
|
||||
|
||||
useAssistantStore.setState({ status: { available: true } as never });
|
||||
const on = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
|
||||
const rows = on.result.current.place(louvre);
|
||||
const alternative = rows.find((r) => 'label' in r && r.label === 'Find an alternative');
|
||||
act(() => (alternative && 'onClick' in alternative ? alternative.onClick() : undefined));
|
||||
expect(openWith).toHaveBeenCalledWith(TRIP, { placeIds: [5] });
|
||||
expect(send).toHaveBeenCalledWith(TRIP, expect.stringContaining('Louvre'));
|
||||
expect(on.result.current.day(3).length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the trip notes', () => {
|
||||
it('FE-AI-POWER-008: notes are kept per trip, and saving empty ones drops the entry', async () => {
|
||||
useAuthStore.setState({ user: buildUser({ id: 1 }) });
|
||||
const updateSetting = vi.fn(async () => {});
|
||||
useSettingsStore.setState({
|
||||
settings: { ...SETTINGS.settings, assistant_memory: { '2': 'Other' } },
|
||||
updateSetting,
|
||||
});
|
||||
const { result } = renderHook(() => useTripMemory(TRIP));
|
||||
expect(result.current.has).toBe(false);
|
||||
await act(async () => void (await result.current.save(' Vegetarian ')));
|
||||
expect(updateSetting).toHaveBeenCalledWith('assistant_memory', { '2': 'Other', [TRIP]: 'Vegetarian' });
|
||||
await act(async () => void (await result.current.save(' ')));
|
||||
expect(updateSetting).toHaveBeenLastCalledWith('assistant_memory', { '2': 'Other' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the map a question carries', () => {
|
||||
it('FE-AI-POWER-009: edges stay on the globe and are rounded to about ten metres', () => {
|
||||
expect(mapViewOf({ south: 35.123456, west: -190, north: 35.2, east: 139.987654 })).toEqual({
|
||||
south: 35.1235,
|
||||
west: -180,
|
||||
north: 35.2,
|
||||
east: 139.9877,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-POWER-010: a question goes out with the map of its own trip in its focus', async () => {
|
||||
const send = vi.fn();
|
||||
useAssistantStore.setState({ mapView: { tripId: TRIP, view: { south: 1, west: 2, north: 3, east: 4 } } });
|
||||
seedChat([]);
|
||||
// The request body is built inside the store; read it from the repo call.
|
||||
const { assistantRepo } = await import('../../repo/assistantRepo');
|
||||
const spy = vi.spyOn(assistantRepo, 'send').mockImplementation(async (_trip, body) => {
|
||||
send(body);
|
||||
throw new Error('stop');
|
||||
});
|
||||
await useAssistantStore.getState().send(TRIP, 'What is here?');
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ focus: { view: { south: 1, west: 2, north: 3, east: 4 } } })
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ChevronDown, ShieldCheck } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { providerLabel } from './providerCatalog';
|
||||
|
||||
/**
|
||||
* Who reads along, said once. A user who connected their own provider agreed
|
||||
* to this while connecting; on the admin's connection the chat says it here,
|
||||
* above the composer, until the user has read it. A new provider set up by
|
||||
* the admin asks again, because the answer is stored per provider and host.
|
||||
*/
|
||||
export default function AssistantPrivacyBanner() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const effective = useAssistantStore((s) => s.status?.effective ?? null);
|
||||
const acked = useAssistantStore((s) => s.status?.privacyAcked ?? true);
|
||||
const ackPrivacy = useAssistantStore((s) => s.ackPrivacy);
|
||||
const [details, setDetails] = useState(false);
|
||||
|
||||
if (!effective || effective.source !== 'instance' || acked) return null;
|
||||
const provider = providerLabel(effective.kind, effective.host);
|
||||
|
||||
const confirm = async () => {
|
||||
try {
|
||||
await ackPrivacy();
|
||||
} catch {
|
||||
toast.error(t('assistant.error.generic'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="trek-assistant-enter rounded-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 text-caption text-content-secondary">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck size={14} aria-hidden="true" className="mt-0.5 flex-shrink-0 text-content-muted" />
|
||||
<p className="m-0 min-w-0 flex-1">{t('assistant.privacy.banner', { provider })}</p>
|
||||
</div>
|
||||
{details && (
|
||||
<ul className="m-0 mt-2 list-disc space-y-1 ps-9">
|
||||
<li>{t('assistant.privacy.shared')}</li>
|
||||
<li>{t('assistant.privacy.notShared')}</li>
|
||||
<li>{t('assistant.privacy.provider', { provider })}</li>
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-end gap-1">
|
||||
{!details && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetails(true)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-caption text-content-muted hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
{t('assistant.privacy.what')}
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void confirm()}
|
||||
className="rounded-md px-2 py-1 text-caption font-medium text-accent-on hover:bg-surface-hover"
|
||||
>
|
||||
{t('assistant.privacy.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useTranslation } from '../../i18n';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { SHARE_MAX_CHARS } from './assistantShare';
|
||||
import type { useAssistantShare } from './useAssistantShare';
|
||||
|
||||
/**
|
||||
* The question before an answer goes to the trip chat: who will see it, the
|
||||
* text exactly as it will arrive, and whether the applied changes go along.
|
||||
* Nothing can be edited here; what goes out is what the preview shows. One
|
||||
* dialog for the window and the phone, which lays it out as a sheet.
|
||||
*/
|
||||
export default function AssistantShareDialog({ share }: { share: ReturnType<typeof useAssistantShare> }) {
|
||||
const { t } = useTranslation();
|
||||
const { preview } = share;
|
||||
return (
|
||||
<ConfirmDialog
|
||||
isOpen={share.previewing}
|
||||
onClose={share.close}
|
||||
onConfirm={() => void share.send()}
|
||||
title={t('assistant.share.title')}
|
||||
message={t('assistant.share.who')}
|
||||
confirmLabel={t('assistant.share.send')}
|
||||
danger={false}
|
||||
>
|
||||
{/* Spaced on its own: the phone's sheet sets the message flush against what follows. */}
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<p
|
||||
dir="auto"
|
||||
className="m-0 max-h-[40vh] overflow-y-auto whitespace-pre-wrap break-words rounded-xl border border-edge-faint bg-surface-secondary p-3 text-body text-content"
|
||||
>
|
||||
{preview.text}
|
||||
</p>
|
||||
{preview.hasChanges && (
|
||||
<label className="flex items-center justify-between gap-3 text-body text-content">
|
||||
<span>{t('assistant.share.includeChanges')}</span>
|
||||
<ToggleSwitch
|
||||
on={share.includeChanges}
|
||||
onToggle={share.toggleChanges}
|
||||
label={t('assistant.share.includeChanges')}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{preview.truncated && (
|
||||
<p className="m-0 text-caption text-content-muted">
|
||||
{t('assistant.share.truncated', { count: SHARE_MAX_CHARS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* FE-AI-SURF-001 to FE-AI-SURF-006
|
||||
*
|
||||
* The chat on the real @assistant-ui runtime, fed from the assistant store:
|
||||
* the empty chat with its suggestions, stored messages as the user sees
|
||||
* them, sending from the composer, and the composer holding back while a
|
||||
* change waits for a decision.
|
||||
*/
|
||||
import type { AiStatusResponse, AssistantMessage } from '@trek/shared';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import AssistantSurface from './AssistantSurface';
|
||||
|
||||
const TRIP = 4;
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const AT = '2026-10-21T08:00:00.000Z';
|
||||
|
||||
const STATUS: AiStatusResponse = {
|
||||
available: true,
|
||||
policy: 'instance',
|
||||
canConnectOwn: false,
|
||||
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
|
||||
own: null,
|
||||
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
|
||||
quota: null,
|
||||
privacyAcked: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
openrouterOAuth: false,
|
||||
};
|
||||
|
||||
const send = vi.fn(async () => {});
|
||||
|
||||
function seed(messages: AssistantMessage[], status: AiStatusResponse = STATUS) {
|
||||
useAssistantStore.setState({
|
||||
status,
|
||||
send,
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderChat() {
|
||||
return render(<AssistantSurface tripId={TRIP} tripTitle="Kyoto" variant="window" />);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom lays nothing out and has no element scrolling; the viewport only needs the call to exist.
|
||||
Element.prototype.scrollTo = vi.fn() as unknown as typeof Element.prototype.scrollTo;
|
||||
});
|
||||
|
||||
beforeEach(() => send.mockClear());
|
||||
afterEach(() => useAssistantStore.setState(INITIAL, true));
|
||||
|
||||
describe('AssistantSurface', () => {
|
||||
it('FE-AI-SURF-001: the empty chat asks what to plan and offers questions that change nothing for an unknown trip', async () => {
|
||||
seed([]);
|
||||
renderChat();
|
||||
expect(await screen.findByText('What are we planning?')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Make a packing list for this trip' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Which days are still empty?' }));
|
||||
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Which days are still empty?'));
|
||||
});
|
||||
|
||||
it('FE-AI-SURF-002: shows the question and the answer, with the steps folded away', async () => {
|
||||
seed([
|
||||
{ id: 'q1', role: 'user', status: 'complete', createdAt: AT, parts: [{ type: 'text', text: 'Plan Friday' }] },
|
||||
{
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: AT,
|
||||
parts: [
|
||||
{ type: 'tool-call', toolCallId: 'r1', toolName: 'get_trip_summary', args: {}, result: { ok: true } },
|
||||
{ type: 'text', text: 'Here is a **plan**.' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
renderChat();
|
||||
expect(await screen.findByText('Plan Friday')).toBeInTheDocument();
|
||||
expect(screen.getByText('plan')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '1 step' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText('What are we planning?')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-SURF-003: Enter sends what was typed, with the trip in the placeholder', async () => {
|
||||
seed([]);
|
||||
renderChat();
|
||||
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
|
||||
fireEvent.change(input, { target: { value: 'Where should we eat?' } });
|
||||
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Where should we eat?');
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Where should we eat?'));
|
||||
});
|
||||
|
||||
it('FE-AI-SURF-004: a change waiting for a decision holds the send button back', async () => {
|
||||
seed([
|
||||
{
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
status: 'awaiting_approval',
|
||||
createdAt: AT,
|
||||
parts: [{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_todo', args: {}, approval: { id: 'w1' } }],
|
||||
},
|
||||
]);
|
||||
renderChat();
|
||||
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
|
||||
fireEvent.change(input, { target: { value: 'Something else' } });
|
||||
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-SURF-005: on the admin connection the chat says once where messages go', async () => {
|
||||
seed([], { ...STATUS, privacyAcked: false });
|
||||
renderChat();
|
||||
expect(
|
||||
await screen.findByText('Your messages and the trip data the assistant reads go to OpenAI.')
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'What gets shared?' }));
|
||||
expect(screen.getByText('Not your password, your files or your other trips.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-SURF-006: an answer shows what its search found and the weather it looked up', async () => {
|
||||
seed([
|
||||
{
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: AT,
|
||||
parts: [
|
||||
{ type: 'tool-call', toolCallId: 's1', toolName: 'search_nearby_places', args: {}, result: { ok: true } },
|
||||
{
|
||||
type: 'data-trek-places',
|
||||
data: {
|
||||
toolCallId: 's1',
|
||||
more: 0,
|
||||
places: [
|
||||
{
|
||||
ref: 's1:0',
|
||||
name: 'Nishiki Market',
|
||||
address: null,
|
||||
lat: 35.005,
|
||||
lng: 135.765,
|
||||
kind: 'food',
|
||||
rating: null,
|
||||
openNow: null,
|
||||
googlePlaceId: null,
|
||||
osmId: null,
|
||||
photoKey: null,
|
||||
inTripPlaceId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'data-trek-weather',
|
||||
data: {
|
||||
days: [
|
||||
{
|
||||
dayId: null,
|
||||
date: '2026-11-02',
|
||||
main: 'Rain',
|
||||
tempMax: 14,
|
||||
tempMin: 9,
|
||||
rainPct: 70,
|
||||
kind: 'forecast',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: 'Rain on Monday, so the market is a good pick.' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
renderChat();
|
||||
expect(await screen.findByRole('region', { name: 'Places found' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Nishiki Market')).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: 'Weather for 1 day' })).toBeInTheDocument();
|
||||
expect(screen.getByText('14°')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import './assistant.css';
|
||||
|
||||
import { AssistantRuntimeProvider } from '@assistant-ui/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import AssistantThread from './AssistantThread';
|
||||
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||
import { useAssistantRuntime } from './useAssistantRuntime';
|
||||
|
||||
export interface AssistantSurfaceProps extends AssistantView {
|
||||
/** Named in the composer's placeholder. */
|
||||
tripTitle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat for one trip, the same on the desktop window and the phone screen.
|
||||
* Its own chunk: this is the only module tree that pulls in @assistant-ui, so
|
||||
* nobody downloads the chat runtime before opening the assistant.
|
||||
*/
|
||||
export default function AssistantSurface({
|
||||
tripId,
|
||||
tripTitle,
|
||||
variant,
|
||||
density,
|
||||
onShowOnMap,
|
||||
onFocusDay,
|
||||
onRevealMap,
|
||||
}: AssistantSurfaceProps) {
|
||||
const runtime = useAssistantRuntime(tripId);
|
||||
// The phone is always compact; a window is what its width says.
|
||||
const view = useMemo(
|
||||
() => ({
|
||||
tripId,
|
||||
variant,
|
||||
density: variant === 'fullscreen' ? ('compact' as const) : (density ?? 'roomy'),
|
||||
onShowOnMap,
|
||||
onFocusDay,
|
||||
onRevealMap,
|
||||
}),
|
||||
[tripId, variant, density, onShowOnMap, onFocusDay, onRevealMap]
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<AssistantViewContext.Provider value={view}>
|
||||
<AssistantThread tripTitle={tripTitle} />
|
||||
</AssistantViewContext.Provider>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { lazyWithRetry } from '../../utils/lazyWithRetry';
|
||||
import ErrorBoundary from '../shared/ErrorBoundary';
|
||||
import { Spinner } from '../shared/Spinner';
|
||||
import type { AssistantSurfaceProps } from './AssistantSurface';
|
||||
|
||||
/**
|
||||
* The chat, loaded when it is first shown: the window and the phone screen
|
||||
* mount this, and the chat runtime arrives in one chunk of its own (kept out
|
||||
* of the offline precache, the assistant needs the network anyway).
|
||||
*/
|
||||
const AssistantSurface = lazyWithRetry(() => import('./AssistantSurface'));
|
||||
|
||||
export default function AssistantSurfaceLazy(props: AssistantSurfaceProps) {
|
||||
const waiting = (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner className="h-5 w-5 border-2 border-edge border-t-[color:var(--text-primary)]" />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
// Boundary outside the Suspense, as in VideoPlayerLazy: a rejected chunk flies past Suspense.
|
||||
<ErrorBoundary
|
||||
boundaryId="assistant"
|
||||
level="panel"
|
||||
variant={props.variant === 'fullscreen' ? 'mobile' : 'desktop'}
|
||||
resetKeys={[props.tripId]}
|
||||
>
|
||||
<Suspense fallback={waiting}>
|
||||
<AssistantSurface key={props.tripId} {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
|
||||
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantComposer from './AssistantComposer';
|
||||
import AssistantError from './AssistantError';
|
||||
import AssistantMessage from './AssistantMessage';
|
||||
import AssistantPrivacyBanner from './AssistantPrivacyBanner';
|
||||
import AssistantWelcome, { AssistantSuggestions } from './AssistantWelcome';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { useAssistantAttach } from './useAssistantAttach';
|
||||
import { useFollowSelectedDay } from './useAssistantFocus';
|
||||
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
|
||||
/**
|
||||
* The chat itself: the messages, and under them the line that says what went
|
||||
* wrong, the privacy note and the composer, held at the bottom while the
|
||||
* messages scroll. A new question moves up to the top of the view, so its
|
||||
* answer reads from its first line instead of chasing the newest one. A
|
||||
* booking file or a place of the trip can be dropped anywhere on it.
|
||||
*/
|
||||
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
|
||||
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
|
||||
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
|
||||
useFollowSelectedDay(tripId);
|
||||
|
||||
const phone = variant === 'fullscreen';
|
||||
const attach = useAssistantAttach(tripId);
|
||||
const placeDrop = useAssistantPlaceDrop(tripId);
|
||||
const DropIcon = placeDrop.dragging ? MapPin : FileUp;
|
||||
// `@` reaches only the trip the planner shows; for another one the hint would promise nothing.
|
||||
const mentionable = useTripStore((s) => s.trip?.id === tripId);
|
||||
|
||||
return (
|
||||
<ThreadPrimitive.Root
|
||||
className="relative flex min-h-0 flex-1 flex-col"
|
||||
{...joinDropZones(attach.dropZone, placeDrop.dropZone)}
|
||||
>
|
||||
{(attach.dragging || placeDrop.dragging) && (
|
||||
<div className="pointer-events-none absolute inset-2 z-20 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-edge bg-surface-elevated text-center backdrop-blur-sm">
|
||||
<DropIcon size={22} aria-hidden="true" className="text-content-secondary" />
|
||||
<span className="max-w-[16rem] text-body font-medium text-content">
|
||||
{t(placeDrop.dragging ? 'assistant.attach.dropPlace' : 'assistant.attach.drop')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
|
||||
{mentionable && (
|
||||
<p className="m-0 pb-1 text-center text-caption text-content-faint">{t('assistant.mention.hint')}</p>
|
||||
)}
|
||||
</AuiIf>
|
||||
<div role="log" aria-label={t('assistant.window.log')} aria-busy={isRunning} className="pt-2">
|
||||
<ThreadPrimitive.Messages>
|
||||
{({ message }) => {
|
||||
const stored = byId.get(message.id);
|
||||
return stored ? <AssistantMessage message={stored} /> : null;
|
||||
}}
|
||||
</ThreadPrimitive.Messages>
|
||||
</div>
|
||||
</ThreadPrimitive.Viewport>
|
||||
{/* Outside the scroller, so it needs no backdrop of its own: on the glass window a second
|
||||
surface behind the composer showed as a box. */}
|
||||
<div className="relative flex flex-shrink-0 flex-col gap-2 px-4 pb-3 pt-2">
|
||||
<ThreadPrimitive.ScrollToBottom
|
||||
aria-label={t('assistant.thread.scrollDown')}
|
||||
className="absolute -top-10 start-1/2 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-edge-faint bg-surface-card text-content-secondary shadow-dropdown hover:text-content disabled:hidden rtl:translate-x-1/2"
|
||||
>
|
||||
<ArrowDown size={15} aria-hidden="true" />
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
{phone && (
|
||||
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||
<AssistantSuggestions suggestions={suggestions} layout="row" />
|
||||
</AuiIf>
|
||||
)}
|
||||
<AssistantError tripId={tripId} />
|
||||
<AssistantPrivacyBanner />
|
||||
<AssistantComposer tripId={tripId} tripTitle={tripTitle} />
|
||||
</div>
|
||||
</ThreadPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import AssistantLiveStep from './AssistantLiveStep';
|
||||
import { toolStepKey } from './assistantModel';
|
||||
import type { ToolStep } from './assistantParts';
|
||||
|
||||
/**
|
||||
* What the assistant looked up on the way to its answer. While a step runs it
|
||||
* says what it does ("Reading your plan", "Searching near Trastevere") and
|
||||
* shimmers; once the run is done the steps fold into one quiet line that
|
||||
* opens to the list. Tool names never show: they are the model's words, not
|
||||
* the user's.
|
||||
*/
|
||||
export default function AssistantToolSteps({ steps }: { steps: ToolStep[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const running = steps.filter((s) => s.running).pop();
|
||||
|
||||
if (running) return <AssistantLiveStep toolCallId={running.toolCallId} toolName={running.toolName} />;
|
||||
|
||||
return (
|
||||
<div className="text-caption text-content-muted">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="-ms-1 inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-caption text-content-muted hover:bg-surface-hover hover:text-content-secondary"
|
||||
>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
aria-hidden="true"
|
||||
className={`transition-transform duration-150 rtl:-scale-x-100 ${open ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
{t('assistant.step.collapsed', { count: steps.length })}
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="m-0 mt-1 list-none space-y-0.5 border-s border-edge-faint ps-3">
|
||||
{steps.map((step) => (
|
||||
<li key={step.toolCallId}>{t(toolStepKey(step.toolName))}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ChevronRight, Luggage } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import EmptyState from '../shared/EmptyState';
|
||||
import { Spinner } from '../shared/Spinner';
|
||||
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
|
||||
|
||||
/**
|
||||
* Outside a trip the assistant first asks which one: the chat, its history
|
||||
* and its changes all belong to one trip. The running and the next trip lead.
|
||||
*/
|
||||
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const trips = useAssistantTripPicker();
|
||||
|
||||
if (trips === null) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner className="h-5 w-5 border-2 border-edge border-t-[color:var(--text-primary)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (trips.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center px-6">
|
||||
<EmptyState scene="idle" size={72} compact surface="var(--bg-elevated)" title={t('assistant.picker.empty')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
|
||||
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
|
||||
{trips.map((trip) => (
|
||||
<li key={trip.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(trip)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-surface-secondary text-content-muted">
|
||||
<Luggage size={15} aria-hidden="true" />
|
||||
</span>
|
||||
<bdi className="min-w-0 flex-1 truncate text-body text-content">{trip.title}</bdi>
|
||||
{trip.badge && (
|
||||
<span className="flex-shrink-0 rounded-full bg-accent-subtle px-2 py-px text-caption text-accent-on">
|
||||
{t(trip.badge === 'now' ? 'assistant.picker.now' : 'assistant.picker.next')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 text-content-faint rtl:-scale-x-100"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* FE-AI-WEATHER-001 to FE-AI-WEATHER-005
|
||||
*
|
||||
* The weather row of an answer: temperatures in the user's unit, a drop only
|
||||
* where rain is likely, the day in focus framed, and a trip day that selects
|
||||
* itself in the planner.
|
||||
*/
|
||||
import type { AssistantWeatherDay } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||
import { inUnit } from './assistantModel';
|
||||
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
const TRIP = 4;
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
const SETTINGS = useSettingsStore.getState();
|
||||
|
||||
function day(date: string, extra: Partial<AssistantWeatherDay> = {}): AssistantWeatherDay {
|
||||
return { dayId: null, date, main: 'Clear', tempMax: 21, tempMin: 12, rainPct: null, kind: 'forecast', ...extra };
|
||||
}
|
||||
|
||||
function renderStrip(days: AssistantWeatherDay[], view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={value}>
|
||||
<AssistantWeatherStrip days={days} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
useSettingsStore.setState(SETTINGS, true);
|
||||
});
|
||||
|
||||
describe('AssistantWeatherStrip', () => {
|
||||
it('FE-AI-WEATHER-001: shows °C in the user unit, without a negative zero', () => {
|
||||
expect(inUnit(21, false)).toBe(21);
|
||||
expect(inUnit(21, true)).toBe(70);
|
||||
expect(inUnit(-0.4, false)).toBe(0);
|
||||
|
||||
useSettingsStore.setState({ settings: { ...SETTINGS.settings, temperature_unit: 'fahrenheit' } });
|
||||
renderStrip([day('2026-11-02')]);
|
||||
expect(screen.getByText('70°')).toBeInTheDocument();
|
||||
expect(screen.getByText('54°')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-WEATHER-002: a drop shows from 30 % rain on, and a climate figure reads as approximate', () => {
|
||||
renderStrip([
|
||||
day('2026-11-02', { rainPct: 29 }),
|
||||
day('2026-11-03', { rainPct: 60 }),
|
||||
day('2026-11-04', { kind: 'climate', tempMax: 18 }),
|
||||
]);
|
||||
expect(screen.queryByText('29%')).toBeNull();
|
||||
expect(screen.getByText('60%')).toBeInTheDocument();
|
||||
expect(screen.getByText('≈18°')).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: 'Weather for 3 days' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-WEATHER-003: a trip day selects itself in the planner, and the day in focus is marked', () => {
|
||||
const onFocusDay = vi.fn();
|
||||
useAssistantStore.setState({
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), focus: { dayId: 31 } } },
|
||||
});
|
||||
renderStrip([day('2026-11-02', { dayId: 31 }), day('2026-11-03', { dayId: 32 })], { onFocusDay });
|
||||
const [first, second] = screen.getAllByRole('button');
|
||||
expect(first).toHaveAttribute('aria-current', 'true');
|
||||
expect(second).not.toHaveAttribute('aria-current');
|
||||
if (second) fireEvent.click(second);
|
||||
expect(onFocusDay).toHaveBeenCalledWith(32);
|
||||
});
|
||||
|
||||
it('FE-AI-WEATHER-004: without a planner or a trip day a chip is only text', () => {
|
||||
renderStrip([day('2026-11-02', { dayId: 31 })]);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
renderStrip([day('2026-11-03')], { onFocusDay: vi.fn() });
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-WEATHER-005: a chip grows with three-digit or approximate figures instead of spilling over', () => {
|
||||
useSettingsStore.setState({ settings: { ...SETTINGS.settings, temperature_unit: 'fahrenheit' } });
|
||||
renderStrip([day('2026-07-02', { kind: 'climate', tempMax: 39, tempMin: 29 })]);
|
||||
expect(screen.getByText('≈102°')).toBeInTheDocument();
|
||||
const chip = screen.getByRole('listitem');
|
||||
expect(chip.className).toContain('min-w-14');
|
||||
expect(chip.className.split(' ')).not.toContain('w-14');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { AssistantWeatherDay } from '@trek/shared';
|
||||
import { Droplet } from 'lucide-react';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { weatherIconFor } from '../Weather/weatherIcons';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { inUnit } from './assistantModel';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
|
||||
/** Rain this likely or more gets its drop. */
|
||||
const RAIN_SHOWN_PCT = 30;
|
||||
|
||||
/** The weekday and the day of the month of a `YYYY-MM-DD` date, read as a calendar date wherever the user is. */
|
||||
function dateParts(iso: string, locale: string): { weekday: string; day: string } {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
|
||||
return { weekday: date.toLocaleDateString(locale, { weekday: 'short' }), day: String(date.getDate()) };
|
||||
}
|
||||
|
||||
function WeatherChip({
|
||||
day,
|
||||
fahrenheit,
|
||||
focused,
|
||||
onPick,
|
||||
}: {
|
||||
day: AssistantWeatherDay;
|
||||
fahrenheit: boolean;
|
||||
focused: boolean;
|
||||
onPick: ((dayId: number) => void) | null;
|
||||
}) {
|
||||
const { t, locale } = useTranslation();
|
||||
const icon = weatherIconFor(day.main);
|
||||
const { weekday, day: dayOfMonth } = dateParts(day.date, locale);
|
||||
// A climate figure is an average for the date, not a forecast; it says so with "≈".
|
||||
const approx = day.kind === 'climate' ? '≈' : '';
|
||||
const max = `${approx}${inUnit(day.tempMax, fahrenheit)}°`;
|
||||
const min = day.tempMin === null ? null : `${inUnit(day.tempMin, fahrenheit)}°`;
|
||||
const rainy = day.rainPct !== null && day.rainPct >= RAIN_SHOWN_PCT;
|
||||
const dayId = day.dayId;
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<span className="text-caption text-content-muted">
|
||||
{weekday} {dayOfMonth}
|
||||
</span>
|
||||
{createElement(icon, { size: 16, strokeWidth: 1.8, 'aria-hidden': true, className: 'text-content-secondary' })}
|
||||
<span className="flex items-baseline gap-1 tabular-nums">
|
||||
<span className="text-body font-semibold text-content">{max}</span>
|
||||
{min && <span className="text-caption text-content-faint">{min}</span>}
|
||||
</span>
|
||||
{rainy && (
|
||||
<Tooltip label={t('assistant.weather.rain', { pct: day.rainPct ?? 0 })} placement="top">
|
||||
<span className="inline-flex items-center gap-0.5 text-caption tabular-nums text-content-muted">
|
||||
<Droplet size={10} aria-hidden="true" />
|
||||
{day.rainPct}%
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const look = `flex min-w-14 flex-shrink-0 snap-start flex-col items-center gap-0.5 rounded-lg border px-1 py-1.5 ${
|
||||
focused ? 'border-edge bg-surface-card' : 'border-transparent bg-surface-secondary'
|
||||
}`;
|
||||
const label = `${weekday} ${dayOfMonth}, ${max}${min ? ` / ${min}` : ''}`;
|
||||
|
||||
if (onPick && dayId !== null) {
|
||||
return (
|
||||
<li className="flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(dayId)}
|
||||
aria-label={label}
|
||||
aria-current={focused || undefined}
|
||||
className={`${look} transition-colors hover:bg-surface-hover`}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li aria-label={label} className={look}>
|
||||
{body}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The weather the assistant looked up, one small chip per day: weekday and
|
||||
* date, the sky, the high in full and the low in quiet, a drop from 30 % rain
|
||||
* on. The day in focus is framed; a chip with a trip day selects that day in
|
||||
* the planner. More than a week scrolls sideways.
|
||||
*/
|
||||
export default function AssistantWeatherStrip({ days }: { days: AssistantWeatherDay[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, onFocusDay } = useAssistantView();
|
||||
const fahrenheit = useSettingsStore((s) => s.settings.temperature_unit) === 'fahrenheit';
|
||||
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
|
||||
|
||||
return (
|
||||
<ul
|
||||
aria-label={t('assistant.weather.strip', { count: days.length })}
|
||||
className="trek-assistant-strip -mx-4 flex snap-x scroll-px-4 gap-1.5 overflow-x-auto px-4"
|
||||
>
|
||||
{days.map((day) => (
|
||||
<WeatherChip
|
||||
key={day.date}
|
||||
day={day}
|
||||
fahrenheit={fahrenheit}
|
||||
focused={day.dayId !== null && day.dayId === focusDayId}
|
||||
onPick={onFocusDay ?? null}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { WeekDay } from './useWeekDays';
|
||||
|
||||
/**
|
||||
* A plan over several days as one row of small tiles: a day per tile, its
|
||||
* number of changes in the corner, taken in or left out as a whole with one
|
||||
* tap. The single changes
|
||||
* stay below it, folded away until asked for.
|
||||
*/
|
||||
export default function AssistantWeekOverview({
|
||||
days,
|
||||
excluded,
|
||||
disabled,
|
||||
onToggleDay,
|
||||
}: {
|
||||
days: readonly WeekDay[];
|
||||
excluded: ReadonlySet<string>;
|
||||
disabled: boolean;
|
||||
onToggleDay: (ids: readonly string[], include: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('assistant.week.title')}
|
||||
className="mb-2 grid gap-2 pt-1"
|
||||
style={{ gridTemplateColumns: `repeat(${Math.min(days.length, 7)}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{days.map((day, i) => {
|
||||
const on = day.ids.filter((id) => !excluded.has(id)).length;
|
||||
const taken = on > 0;
|
||||
const count = taken ? on : day.ids.length;
|
||||
const name = `${day.weekday ?? t('assistant.week.day')} ${day.n}`;
|
||||
return (
|
||||
<button
|
||||
key={day.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={taken}
|
||||
aria-label={`${name}, ${t('assistant.week.changes', { count })}`}
|
||||
title={t('assistant.week.changes', { count })}
|
||||
onClick={() => onToggleDay(day.ids, !taken)}
|
||||
className={`trek-assistant-enter relative flex min-w-0 flex-col items-center rounded-lg py-1.5 outline-none transition-all focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-default ${
|
||||
taken
|
||||
? 'bg-accent text-accent-text shadow-sm hover:-translate-y-px hover:shadow-md'
|
||||
: 'bg-surface-secondary text-content-faint hover:text-content-muted'
|
||||
}`}
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<span className={`text-caption leading-tight ${taken ? 'opacity-75' : ''}`}>
|
||||
{day.weekday ?? t('assistant.week.day')}
|
||||
</span>
|
||||
<span className={`text-body font-semibold tabular-nums leading-tight ${taken ? '' : 'line-through'}`}>
|
||||
{day.n}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute -end-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full px-1 text-caption font-semibold tabular-nums leading-none ring-2 ring-surface-card ${
|
||||
taken ? 'bg-surface-card text-content' : 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ThreadPrimitive } from '@assistant-ui/react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import EmptyState from '../shared/EmptyState';
|
||||
import AssistantHints, { MAX_VISIBLE_HINTS } from './AssistantHints';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { useAssistantHints } from './useAssistantHints';
|
||||
import type { ReadySuggestion } from './useAssistantSuggestions';
|
||||
|
||||
/** Suggestions left under a full list of hints, so both fit the smallest window. */
|
||||
const SUGGESTIONS_UNDER_HINTS = 2;
|
||||
|
||||
/**
|
||||
* The empty chat: the mascot, one question, and a few things to ask that fit
|
||||
* where the trip stands. A suggestion sends itself; there is nothing to edit
|
||||
* first. On the phone the suggestions sit by the composer instead, where the
|
||||
* thumb is, so this shows them only when `withSuggestions` asks for it.
|
||||
* Between the two sit the plan hints, when the trip has any.
|
||||
*/
|
||||
export default function AssistantWelcome({
|
||||
suggestions,
|
||||
canEdit,
|
||||
withSuggestions,
|
||||
}: {
|
||||
suggestions: ReadySuggestion[];
|
||||
canEdit: boolean;
|
||||
withSuggestions: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
const hints = useAssistantHints(tripId, { withStays: true });
|
||||
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
|
||||
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
|
||||
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={t('assistant.empty.title')} />
|
||||
{!canEdit && (
|
||||
<p className="m-0 max-w-xs text-center text-caption text-content-muted">{t('assistant.empty.readOnly')}</p>
|
||||
)}
|
||||
<AssistantHints view={hints} phone={variant === 'fullscreen'} />
|
||||
{withSuggestions && offered.length > 0 && <AssistantSuggestions suggestions={offered} layout="stack" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssistantSuggestions({
|
||||
suggestions,
|
||||
layout,
|
||||
}: {
|
||||
suggestions: ReadySuggestion[];
|
||||
/** 'stack' under the mascot, 'row' as a strip of chips over the composer. */
|
||||
layout: 'stack' | 'row';
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
layout === 'stack'
|
||||
? 'trek-stagger flex w-full max-w-sm flex-col gap-1.5'
|
||||
: 'trek-stagger -mx-1 flex gap-2 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]'
|
||||
}
|
||||
>
|
||||
{suggestions.map((s) => (
|
||||
<ThreadPrimitive.Suggestion
|
||||
key={s.id}
|
||||
prompt={s.prompt}
|
||||
send
|
||||
dir="auto"
|
||||
className={
|
||||
layout === 'stack'
|
||||
? 'rounded-xl border border-edge-faint bg-surface-card px-3.5 py-2.5 text-start text-body text-content transition-colors hover:border-edge hover:bg-surface-hover'
|
||||
: 'min-h-[44px] flex-shrink-0 whitespace-nowrap rounded-full border border-edge-faint bg-surface-card px-4 text-body text-content'
|
||||
}
|
||||
>
|
||||
{s.prompt}
|
||||
</ThreadPrimitive.Suggestion>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* FE-AI-WIN-001 to FE-AI-WIN-010
|
||||
*
|
||||
* The desktop window around the chat: when it shows at all, the button it
|
||||
* opens from, the trip picker outside a trip, closing that keeps the chat, and
|
||||
* Escape, which stops an answer before it closes anything (and leaves the
|
||||
* header's menu to itself), and where the focus goes on the way. The geometry is
|
||||
* useDragResize's and has its own tests; here it is a fixed rect.
|
||||
*/
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { tripRepo } from '../../repo/tripRepo';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import type { Trip } from '../../types';
|
||||
import AssistantWindow from './AssistantWindow';
|
||||
|
||||
vi.mock('../../hooks/useDragResize', () => ({
|
||||
useDragResize: () => ({
|
||||
rect: { x: 600, y: 120, width: 400, height: 600 },
|
||||
active: false,
|
||||
handleProps: {},
|
||||
edgeProps: () => ({}),
|
||||
onKeyDown: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./AssistantSurfaceLazy', () => ({
|
||||
default: ({
|
||||
tripId,
|
||||
tripTitle,
|
||||
density,
|
||||
onFocusDay,
|
||||
}: {
|
||||
tripId: number;
|
||||
tripTitle: string;
|
||||
density?: string;
|
||||
onFocusDay?: (dayId: number) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<p>
|
||||
chat {tripId} {tripTitle}
|
||||
</p>
|
||||
<output data-testid="density">{density}</output>
|
||||
{onFocusDay && (
|
||||
<button type="button" onClick={() => onFocusDay(12)}>
|
||||
pick day
|
||||
</button>
|
||||
)}
|
||||
<textarea aria-label="composer" />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const TRIP = { id: 4, title: 'Kyoto' };
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
|
||||
|
||||
const loadStatus = vi.fn(async () => {});
|
||||
const resume = vi.fn(async () => {});
|
||||
const cancel = vi.fn();
|
||||
|
||||
function seed(window: { open: boolean; minimized: boolean }, isRunning = false) {
|
||||
// A chat the test before unmounted stops itself on the way out; that call is not this test's.
|
||||
cancel.mockClear();
|
||||
useAssistantStore.setState({
|
||||
status: STATUS,
|
||||
window,
|
||||
loadStatus,
|
||||
resume,
|
||||
cancel,
|
||||
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(tripRepo, 'list').mockResolvedValue({
|
||||
trips: [{ id: 9, title: 'Lisbon', start_date: '2026-10-01', end_date: '2026-12-01' } as Trip],
|
||||
archivedTrips: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AssistantWindow', () => {
|
||||
it('FE-AI-WIN-001: shows nothing at all without a connection, but asks for the status', () => {
|
||||
seed({ open: true, minimized: false });
|
||||
useAssistantStore.setState({ status: { ...STATUS, available: false } });
|
||||
const { container } = render(<AssistantWindow trip={TRIP} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(loadStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-002: closed, the planner shows the round button, and it opens the chat on this trip', () => {
|
||||
seed({ open: false, minimized: false });
|
||||
render(<AssistantWindow trip={TRIP} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
expect(screen.getByRole('dialog', { name: 'Assistant' })).toHaveTextContent('chat 4 Kyoto');
|
||||
expect(resume).toHaveBeenCalledWith(TRIP.id);
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-003: closed outside a trip, nothing floats', () => {
|
||||
seed({ open: false, minimized: false });
|
||||
const { container } = render(<AssistantWindow trip={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={null} />);
|
||||
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
|
||||
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-005: closed while writing, the chat stays mounted and the round button breathes until it is opened', () => {
|
||||
seed({ open: true, minimized: true }, true);
|
||||
render(<AssistantWindow trip={TRIP} />);
|
||||
expect(screen.getByText('chat 4 Kyoto')).not.toBeVisible();
|
||||
const button = screen.getByRole('button', { name: 'Assistant' });
|
||||
expect(button).toHaveAttribute('aria-busy', 'true');
|
||||
fireEvent.click(button);
|
||||
expect(useAssistantStore.getState().window.minimized).toBe(false);
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-006: Escape stops a running answer first and only then closes', () => {
|
||||
seed({ open: true, minimized: false }, true);
|
||||
render(<AssistantWindow trip={TRIP} />);
|
||||
const dialog = screen.getByRole('dialog', { name: 'Assistant' });
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' });
|
||||
expect(cancel).toHaveBeenCalledWith(TRIP.id);
|
||||
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||
|
||||
act(() =>
|
||||
useAssistantStore.setState({
|
||||
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning: false } },
|
||||
})
|
||||
);
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' });
|
||||
expect(useAssistantStore.getState().window.open).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-007: the head has one close button, which hides the window and keeps the chat', () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={TRIP} />);
|
||||
expect(screen.queryByRole('button', { name: 'Minimize' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(useAssistantStore.getState().window.minimized).toBe(true);
|
||||
expect(screen.getByText('chat 4 Kyoto')).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-008: closing hands the focus to the round button, opening hands it back to the composer', async () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={TRIP} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
const button = await screen.findByRole('button', { name: 'Assistant' });
|
||||
expect(button).toHaveFocus();
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(screen.getByLabelText('composer')).toHaveFocus());
|
||||
});
|
||||
|
||||
it("FE-AI-WIN-009: Escape in the header's menu closes the menu, not the window", async () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={TRIP} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More options' }));
|
||||
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
|
||||
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
|
||||
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
|
||||
seed({ open: true, minimized: false });
|
||||
const onFocusDay = vi.fn();
|
||||
const { unmount } = render(<AssistantWindow trip={TRIP} onFocusDay={onFocusDay} />);
|
||||
expect(useAssistantStore.getState().windowRect).toEqual({ x: 600, y: 120, width: 400, height: 600 });
|
||||
expect(screen.getByTestId('density')).toHaveTextContent('compact');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pick day' }));
|
||||
expect(onFocusDay).toHaveBeenCalledWith(12);
|
||||
unmount();
|
||||
expect(useAssistantStore.getState().windowRect).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import './assistant.css';
|
||||
|
||||
import type { AssistantApplied } from '@trek/shared';
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { type ResizeEdge, type WindowRect, type WindowSize, useDragResize } from '../../hooks/useDragResize';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import AssistantHeader from './AssistantHeader';
|
||||
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
|
||||
import AssistantSurfaceLazy from './AssistantSurfaceLazy';
|
||||
import AssistantTripPicker from './AssistantTripPicker';
|
||||
import { useAssistantHotkey } from './useAssistantHotkey';
|
||||
import { useAssistantThread } from './useAssistantThread';
|
||||
|
||||
/**
|
||||
* The assistant on the desktop: a glass window floating over the app, moved
|
||||
* by its head, resized from any edge or corner, kept inside the screen. It
|
||||
* remembers where it was. A double click on the head switches between the
|
||||
* compact window and a large one; let go near a corner and it settles there;
|
||||
* Alt+arrows move it and Alt+Shift+arrows resize it from the keyboard.
|
||||
*
|
||||
* The head's close button only hides it: the conversation goes on behind it,
|
||||
* and the planner shows the round button it opens from again, breathing while
|
||||
* an answer is still being written. Escape stops an answer first, then
|
||||
* closes, and the focus goes back to where it was.
|
||||
*
|
||||
* Mounted once for the whole app; it shows nothing until the assistant has a
|
||||
* connection this user can use.
|
||||
*/
|
||||
|
||||
interface WindowTrip {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface AssistantWindowProps {
|
||||
/** The trip on screen; null outside a trip, where the window asks which one first. */
|
||||
trip: WindowTrip | null;
|
||||
/** Shows an applied change in the planner on screen. */
|
||||
onShowOnMap?: (applied: AssistantApplied) => void;
|
||||
/** Selects a day in the planner on screen. */
|
||||
onFocusDay?: (dayId: number) => void;
|
||||
}
|
||||
|
||||
/** Narrower than this, the chat lays its cards out compact. */
|
||||
const ROOMY_WIDTH = 480;
|
||||
|
||||
/** Where the window's place and size are kept between visits. */
|
||||
const WINDOW_STORAGE = 'trek.assistantWindow';
|
||||
const MIN_SIZE: WindowSize = { width: 340, height: 420 };
|
||||
const DEFAULT_SIZE: WindowSize = { width: 400, height: 600 };
|
||||
/** Never closer to the screen's edge than this. */
|
||||
const MARGIN = 16;
|
||||
/** The default distance from the edges, and how near a corner a released window snaps into it. */
|
||||
const GAP = 24;
|
||||
const expandedSize = (viewport: WindowSize): WindowSize => ({
|
||||
width: Math.min(720, viewport.width - 2 * MARGIN),
|
||||
height: Math.round(viewport.height * 0.8),
|
||||
});
|
||||
|
||||
const EDGES: { edge: ResizeEdge; className: string; cursor: 'ns' | 'ew' | 'corner-start' | 'corner-end' }[] = [
|
||||
{ edge: 'n', className: 'inset-x-3 -top-1 h-2', cursor: 'ns' },
|
||||
{ edge: 's', className: 'inset-x-3 -bottom-1 h-2', cursor: 'ns' },
|
||||
{ edge: 'start', className: 'inset-y-3 -start-1 w-2', cursor: 'ew' },
|
||||
{ edge: 'end', className: 'inset-y-3 -end-1 w-2', cursor: 'ew' },
|
||||
{ edge: 'n-start', className: '-start-1 -top-1 h-4 w-4', cursor: 'corner-start' },
|
||||
{ edge: 's-end', className: '-bottom-1 -end-1 h-4 w-4', cursor: 'corner-start' },
|
||||
{ edge: 'n-end', className: '-end-1 -top-1 h-4 w-4', cursor: 'corner-end' },
|
||||
{ edge: 's-start', className: '-bottom-1 -start-1 h-4 w-4', cursor: 'corner-end' },
|
||||
];
|
||||
|
||||
function edgeCursor(kind: (typeof EDGES)[number]['cursor'], rtl: boolean): string {
|
||||
if (kind === 'ns') return 'ns-resize';
|
||||
if (kind === 'ew') return 'ew-resize';
|
||||
// The top-start corner is the top left in a left-to-right page and the top right in Arabic.
|
||||
return (kind === 'corner-start') !== rtl ? 'nwse-resize' : 'nesw-resize';
|
||||
}
|
||||
|
||||
/** The fixed navbar's height (`--nav-h`, a calc() and 0 where there is none), measured. */
|
||||
function navbarHeight(): number {
|
||||
if (typeof document === 'undefined') return 0;
|
||||
const probe = document.createElement('div');
|
||||
probe.style.cssText = 'position:fixed;visibility:hidden;height:var(--nav-h, 0px)';
|
||||
document.body.appendChild(probe);
|
||||
const height = probe.offsetHeight;
|
||||
probe.remove();
|
||||
return height;
|
||||
}
|
||||
|
||||
/** Where the floating button's middle sits inside the window, so the window grows out of it. */
|
||||
function originFrom(rect: WindowRect, rtl: boolean): string {
|
||||
const fromStart = window.innerWidth - LAUNCHER_INSET - LAUNCHER_SIZE / 2 - rect.x;
|
||||
const x = rtl ? rect.width - fromStart : fromStart;
|
||||
const y = window.innerHeight - LAUNCHER_INSET - LAUNCHER_SIZE / 2 - rect.y;
|
||||
return `${Math.round(x)}px ${Math.round(y)}px`;
|
||||
}
|
||||
|
||||
function ChatWindow({
|
||||
trip,
|
||||
hidden,
|
||||
onShowOnMap,
|
||||
onFocusDay,
|
||||
}: {
|
||||
trip: WindowTrip;
|
||||
hidden: boolean;
|
||||
onShowOnMap?: (applied: AssistantApplied) => void;
|
||||
onFocusDay?: (dayId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const thread = useAssistantThread(trip.id);
|
||||
const close = useAssistantStore((s) => s.close);
|
||||
const setMinimized = useAssistantStore((s) => s.setMinimized);
|
||||
const setWindowRect = useAssistantStore((s) => s.setWindowRect);
|
||||
// The navbar sits on the same layer and later in the page: the head never goes under it.
|
||||
const [topInset] = useState(navbarHeight);
|
||||
const frame = useDragResize({
|
||||
storageKey: WINDOW_STORAGE,
|
||||
minSize: MIN_SIZE,
|
||||
defaultSize: DEFAULT_SIZE,
|
||||
expandedSize,
|
||||
margin: MARGIN,
|
||||
gap: GAP,
|
||||
topInset,
|
||||
});
|
||||
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||
const [origin] = useState(() => originFrom(frame.rect, rtl));
|
||||
const returnFocus = useRef<Element | null>(typeof document !== 'undefined' ? document.activeElement : null);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const wasHidden = useRef(hidden);
|
||||
const { x, y, width, height } = frame.rect;
|
||||
|
||||
// The map frames its view around the window: it learns where the window
|
||||
// settled after a move or a resize, not every step of the drag.
|
||||
useEffect(() => {
|
||||
if (hidden) setWindowRect(null);
|
||||
else if (!frame.active) setWindowRect({ x, y, width, height });
|
||||
}, [hidden, frame.active, x, y, width, height, setWindowRect]);
|
||||
useEffect(() => () => setWindowRect(null), [setWindowRect]);
|
||||
|
||||
// Back to whatever had the focus before the window opened.
|
||||
useEffect(() => {
|
||||
const target = returnFocus.current;
|
||||
return () => {
|
||||
if (target instanceof HTMLElement && target.isConnected) target.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Restored from the pill: back into the composer, where the user left off.
|
||||
useEffect(() => {
|
||||
if (wasHidden.current && !hidden) sectionRef.current?.querySelector('textarea')?.focus();
|
||||
wasHidden.current = hidden;
|
||||
}, [hidden]);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
||||
// Keys from a portal (the header's menu) bubble here through React; they are not the window's.
|
||||
if (!e.currentTarget.contains(e.target as Node)) return;
|
||||
frame.onKeyDown(e);
|
||||
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
||||
e.preventDefault();
|
||||
if (thread.isRunning) thread.stop();
|
||||
else close();
|
||||
};
|
||||
|
||||
const style: CSSProperties = {
|
||||
insetInlineStart: frame.rect.x,
|
||||
top: frame.rect.y,
|
||||
width: frame.rect.width,
|
||||
height: frame.rect.height,
|
||||
transformOrigin: origin,
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
role="dialog"
|
||||
aria-label={t('assistant.window.title')}
|
||||
// Hidden, not unmounted, while minimized: the answer being written keeps coming in.
|
||||
hidden={hidden}
|
||||
onKeyDown={onKeyDown}
|
||||
style={style}
|
||||
className={`trek-assistant-pop fixed z-[var(--z-nav)] ${hidden ? 'hidden' : 'flex'} flex-col overflow-visible rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150 ${
|
||||
frame.active ? 'select-none' : 'trek-assistant-settle'
|
||||
}`}
|
||||
>
|
||||
{EDGES.map(({ edge, className, cursor }) => (
|
||||
<div
|
||||
key={edge}
|
||||
aria-hidden="true"
|
||||
{...frame.edgeProps(edge)}
|
||||
className={`absolute z-10 touch-none ${className}`}
|
||||
style={{ cursor: edgeCursor(cursor, rtl) }}
|
||||
/>
|
||||
))}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
|
||||
<AssistantHeader
|
||||
tripId={trip.id}
|
||||
thread={thread}
|
||||
tripTitle={trip.title}
|
||||
dragProps={frame.handleProps}
|
||||
// Closing only hides the window: an answer being written keeps coming in,
|
||||
// and the floating button says so until it is opened again.
|
||||
onClose={() => setMinimized(true)}
|
||||
/>
|
||||
<AssistantSurfaceLazy
|
||||
tripId={trip.id}
|
||||
tripTitle={trip.title}
|
||||
variant="window"
|
||||
density={width < ROOMY_WIDTH ? 'compact' : 'roomy'}
|
||||
onShowOnMap={onShowOnMap}
|
||||
onFocusDay={onFocusDay}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantWindow({ trip, onShowOnMap, onFocusDay }: AssistantWindowProps) {
|
||||
const navigate = useNavigate();
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open);
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const toggle = useAssistantStore((s) => s.toggle);
|
||||
const [picked, setPicked] = useState<WindowTrip | null>(null);
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
useAssistantHotkey(available, toggle);
|
||||
|
||||
// A trip picked outside the planner is only for this opening; the next one asks again.
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (!open) setPicked(null);
|
||||
}
|
||||
|
||||
const current = trip ?? picked;
|
||||
const showPicked = useCallback(() => {
|
||||
if (picked) void navigate(`/trips/${picked.id}`);
|
||||
}, [navigate, picked]);
|
||||
|
||||
if (!available) return null;
|
||||
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
|
||||
if (!current) return <PickerWindow onPick={setPicked} />;
|
||||
|
||||
// A change in a trip the planner does not show is shown by opening that trip.
|
||||
const onScreen = !!trip && current.id === trip.id;
|
||||
const showOnMap = onScreen ? onShowOnMap : showPicked;
|
||||
return (
|
||||
<>
|
||||
<ChatWindow
|
||||
trip={current}
|
||||
hidden={minimized}
|
||||
onShowOnMap={showOnMap}
|
||||
// Only the planner on screen has days to select.
|
||||
onFocusDay={onScreen ? onFocusDay : undefined}
|
||||
/>
|
||||
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerWindow({ onPick }: { onPick: (trip: WindowTrip) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const close = useAssistantStore((s) => s.close);
|
||||
return (
|
||||
<section
|
||||
role="dialog"
|
||||
aria-labelledby="assistant-picker-title"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && !e.defaultPrevented) close();
|
||||
}}
|
||||
className="trek-assistant-pop fixed z-[var(--z-nav)] flex max-h-[min(32rem,calc(100vh-6rem))] w-[22rem] flex-col overflow-hidden rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150"
|
||||
style={{ insetInlineEnd: LAUNCHER_INSET, bottom: LAUNCHER_INSET, transformOrigin: 'bottom' }}
|
||||
>
|
||||
<header className="flex h-10 flex-shrink-0 items-center gap-2 border-b border-edge-faint pe-1.5 ps-3">
|
||||
<Sparkles size={14} aria-hidden="true" className="flex-shrink-0 text-content-muted" />
|
||||
<h2 id="assistant-picker-title" className="m-0 flex-1 truncate text-body font-semibold text-content">
|
||||
{t('assistant.picker.title')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label={t('common.close')}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-content-muted hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<AssistantTripPicker onPick={({ id, title }) => onPick({ id, title })} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/* The assistant's motion. Calm on purpose: things arrive, nothing bounces.
|
||||
Every rule here has a still form under reduced motion, from the OS setting
|
||||
or from the user's own switch (data-reduce-motion on <html>). */
|
||||
|
||||
/* The window grows out of the button that opened it. The origin is set inline,
|
||||
because it is wherever the button sits relative to the window. */
|
||||
@keyframes trek-assistant-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.trek-assistant-pop {
|
||||
animation: trek-assistant-pop 240ms var(--ease-drawer);
|
||||
}
|
||||
|
||||
/* Settling after a drag: the window glides into a corner or its other size,
|
||||
but never while a pointer is moving it. */
|
||||
.trek-assistant-settle {
|
||||
transition-property: inset-inline-start, top, width, height;
|
||||
transition-duration: 220ms;
|
||||
transition-timing-function: var(--ease-drawer);
|
||||
}
|
||||
|
||||
/* A new message rises in. */
|
||||
.trek-assistant-enter {
|
||||
animation: trek-fade-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
}
|
||||
|
||||
/* A reading step names what it does while it runs: the words shimmer. */
|
||||
.trek-assistant-shimmer {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--text-muted) 0%,
|
||||
var(--text-muted) 40%,
|
||||
var(--text-primary) 50%,
|
||||
var(--text-muted) 60%,
|
||||
var(--text-muted) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: trek-shimmer 1.8s linear infinite;
|
||||
}
|
||||
|
||||
/* The caret at the end of an answer that is still being written: after the
|
||||
last word, so in the last item of a closing list. */
|
||||
@keyframes trek-assistant-caret {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
.trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
|
||||
.trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
margin-inline-start: 2px;
|
||||
vertical-align: -0.15em;
|
||||
border-radius: 1px;
|
||||
background: var(--text-primary);
|
||||
animation: trek-assistant-caret 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Markdown in an answer: the planner's text scale, tight rhythm, no images. */
|
||||
.trek-assistant-md > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.trek-assistant-md > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.trek-assistant-md p,
|
||||
.trek-assistant-md ul,
|
||||
.trek-assistant-md ol,
|
||||
.trek-assistant-md pre,
|
||||
.trek-assistant-md blockquote,
|
||||
.trek-assistant-md table {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.trek-assistant-md ul {
|
||||
list-style: disc;
|
||||
padding-inline-start: 1.25em;
|
||||
}
|
||||
.trek-assistant-md ol {
|
||||
list-style: decimal;
|
||||
padding-inline-start: 1.25em;
|
||||
}
|
||||
.trek-assistant-md li + li {
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
.trek-assistant-md h1,
|
||||
.trek-assistant-md h2,
|
||||
.trek-assistant-md h3,
|
||||
.trek-assistant-md h4 {
|
||||
margin: 0.75em 0 0.25em;
|
||||
font-weight: 600;
|
||||
font-size: inherit;
|
||||
}
|
||||
.trek-assistant-md strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.trek-assistant-md a {
|
||||
color: var(--accent-on);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.trek-assistant-md code {
|
||||
padding: 0.05em 0.3em;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.trek-assistant-md pre {
|
||||
overflow-x: auto;
|
||||
padding: 0.6em 0.75em;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
.trek-assistant-md pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.trek-assistant-md blockquote {
|
||||
padding-inline-start: 0.75em;
|
||||
border-inline-start: 2px solid var(--border-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.trek-assistant-md table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.trek-assistant-md th,
|
||||
.trek-assistant-md td {
|
||||
padding: 0.25em 0.6em;
|
||||
border: 1px solid var(--border-faint);
|
||||
text-align: start;
|
||||
}
|
||||
.trek-assistant-md hr {
|
||||
margin: 0.75em 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
}
|
||||
|
||||
/* A closed window still writing an answer: a ring breathes around the floating button. */
|
||||
@keyframes trek-assistant-busy {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.45;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.18);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.trek-assistant-busy {
|
||||
animation: trek-assistant-busy 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* The dot beside what the assistant does right now: a soft beat, a halo going out. */
|
||||
@keyframes trek-assistant-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 6px transparent;
|
||||
}
|
||||
}
|
||||
.trek-assistant-pulse {
|
||||
animation: trek-assistant-pulse 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-assistant-pop,
|
||||
.trek-assistant-enter {
|
||||
animation: trek-backdrop-enter 120ms ease-out;
|
||||
}
|
||||
.trek-assistant-settle {
|
||||
transition: none;
|
||||
}
|
||||
.trek-assistant-busy,
|
||||
.trek-assistant-pulse {
|
||||
animation: none;
|
||||
}
|
||||
.trek-assistant-shimmer {
|
||||
animation: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
|
||||
.trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-pop,
|
||||
[data-reduce-motion] .trek-assistant-enter {
|
||||
animation: trek-backdrop-enter 120ms ease-out;
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-settle {
|
||||
transition: none;
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-busy,
|
||||
[data-reduce-motion] .trek-assistant-pulse {
|
||||
animation: none;
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-shimmer {
|
||||
animation: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
|
||||
[data-reduce-motion] .trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* A row of cards that scrolls sideways (found places, the weather): no bar,
|
||||
and the edges fade where there is more to scroll to. */
|
||||
.trek-assistant-strip {
|
||||
--strip-fade-start: 0px;
|
||||
--strip-fade-end: 0px;
|
||||
scrollbar-width: none;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black var(--strip-fade-start),
|
||||
black calc(100% - var(--strip-fade-end)),
|
||||
transparent
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black var(--strip-fade-start),
|
||||
black calc(100% - var(--strip-fade-end)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.trek-assistant-strip::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
[dir='rtl'] .trek-assistant-strip {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to left,
|
||||
transparent,
|
||||
black var(--strip-fade-start),
|
||||
black calc(100% - var(--strip-fade-end)),
|
||||
transparent
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to left,
|
||||
transparent,
|
||||
black var(--strip-fade-start),
|
||||
black calc(100% - var(--strip-fade-end)),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.trek-assistant-strip[data-fade-start] {
|
||||
--strip-fade-start: 24px;
|
||||
}
|
||||
.trek-assistant-strip[data-fade-end] {
|
||||
--strip-fade-end: 24px;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { AssistantMessage } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The next questions an answer suggests, as one tap each: read from what the
|
||||
* answer holds, not asked from the model, so they cost nothing and come at
|
||||
* once. Places found ask to narrow them, a plan asks to go on, a forecast asks
|
||||
* for the other weather. Three at most; the first kind the answer holds wins.
|
||||
*/
|
||||
|
||||
export interface FollowUp {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
const PLACES: readonly FollowUp[] = [
|
||||
{ id: 'cheaper', labelKey: 'assistant.followUp.cheaper' },
|
||||
{ id: 'closer', labelKey: 'assistant.followUp.closer' },
|
||||
{ id: 'openNow', labelKey: 'assistant.followUp.openNow' },
|
||||
];
|
||||
|
||||
const PLAN: readonly FollowUp[] = [
|
||||
{ id: 'nextDay', labelKey: 'assistant.followUp.nextDay' },
|
||||
{ id: 'relaxed', labelKey: 'assistant.followUp.relaxed' },
|
||||
{ id: 'dinners', labelKey: 'assistant.followUp.dinners' },
|
||||
];
|
||||
|
||||
const WEATHER: readonly FollowUp[] = [
|
||||
{ id: 'rain', labelKey: 'assistant.followUp.rain' },
|
||||
{ id: 'sun', labelKey: 'assistant.followUp.sun' },
|
||||
];
|
||||
|
||||
const GENERAL: readonly FollowUp[] = [
|
||||
{ id: 'more', labelKey: 'assistant.followUp.more' },
|
||||
{ id: 'next', labelKey: 'assistant.followUp.next' },
|
||||
];
|
||||
|
||||
/** What an answer offers to ask next; changes are offered only to someone who may make them. */
|
||||
export function followUpsFor(message: AssistantMessage, canEdit: boolean): readonly FollowUp[] {
|
||||
const types = new Set(message.parts.map((part) => part.type));
|
||||
if (canEdit && (types.has('data-trek-change') || types.has('data-trek-applied'))) return PLAN;
|
||||
if (types.has('data-trek-places')) return PLACES;
|
||||
if (types.has('data-trek-weather')) return WEATHER;
|
||||
return types.has('text') ? GENERAL : [];
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* FE-AI-HINT-001 to FE-AI-HINT-019
|
||||
*
|
||||
* The plan hints, worked out from the trip alone: each rule on both sides of
|
||||
* its threshold, the data that silences it, and the order, ids and clock
|
||||
* helpers they rest on.
|
||||
*/
|
||||
import type { PlaceOpeningHours } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
|
||||
import type { Accommodation, Assignment, Day, Place, Reservation } from '../../types';
|
||||
import { formatClockTime } from '../../utils/formatters';
|
||||
import { type AssistantHint, GAP_MIN_MINUTES, LONG_LEG_KM, tripHints, type TripHintsInput } from './assistantHintRules';
|
||||
import {
|
||||
bookingSpan,
|
||||
clockMinutes,
|
||||
clockText,
|
||||
noteMinutes,
|
||||
spansOverlap,
|
||||
stopSpan,
|
||||
weekdayMondayFirst,
|
||||
} from './assistantHintsTime';
|
||||
|
||||
const TODAY = '2026-06-10';
|
||||
/** A Friday. */
|
||||
const FRIDAY = '2026-06-12';
|
||||
/** One kilometre north, in degrees of latitude on the sphere haversineKm uses. */
|
||||
const KM = 1 / ((2 * Math.PI * 6371) / 360);
|
||||
|
||||
function day(id: number, date: string | null = FRIDAY, extra: Partial<Day> = {}): Day {
|
||||
return buildDay({ id, date, ...extra });
|
||||
}
|
||||
|
||||
interface StopOptions {
|
||||
time?: string;
|
||||
end?: string;
|
||||
duration?: number;
|
||||
lat?: number;
|
||||
place?: Partial<Place>;
|
||||
}
|
||||
|
||||
function stop(id: number, dayId: number, opts: StopOptions = {}, extra: Partial<Assignment> = {}): Assignment {
|
||||
const place = buildPlace({
|
||||
id: 1000 + id,
|
||||
name: `Stop ${id}`,
|
||||
place_time: opts.time ?? null,
|
||||
end_time: opts.end ?? null,
|
||||
duration_minutes: opts.duration ?? null,
|
||||
lat: opts.lat ?? 0,
|
||||
lng: 0,
|
||||
...opts.place,
|
||||
});
|
||||
return buildAssignment({ id, day_id: dayId, place_id: place.id, order_index: id, place, ...extra });
|
||||
}
|
||||
|
||||
function input(over: Partial<TripHintsInput> & { stops?: Assignment[] }): TripHintsInput {
|
||||
const { stops = [], ...rest } = over;
|
||||
const assignments: TripHintsInput['assignments'] = {};
|
||||
for (const a of stops) (assignments[String(a.day_id)] ??= []).push(a);
|
||||
return {
|
||||
today: TODAY,
|
||||
days: [day(1)],
|
||||
assignments,
|
||||
places: stops.map((a) => a.place as Place),
|
||||
reservations: [],
|
||||
dayNotes: {},
|
||||
accommodations: [],
|
||||
distanceUnit: 'metric',
|
||||
is12h: false,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function booking(over: Partial<Reservation>): Reservation {
|
||||
return buildReservation({ day_id: 1, ...over });
|
||||
}
|
||||
|
||||
function stay(start: number, end: number): Accommodation {
|
||||
return { id: start * 100 + end, trip_id: 1, start_day_id: start, end_day_id: end };
|
||||
}
|
||||
|
||||
function hours(closedOn: number | null, open = '09:00', close = '17:00'): string {
|
||||
const week: PlaceOpeningHours = Array.from({ length: 7 }, (_, i) =>
|
||||
i === closedOn ? { closed: true } : { closed: false, open, close }
|
||||
);
|
||||
return JSON.stringify(week);
|
||||
}
|
||||
|
||||
const only = (hints: AssistantHint[], rule: AssistantHint['rule']) => hints.filter((h) => h.rule === rule);
|
||||
|
||||
describe('overlap', () => {
|
||||
it('FE-AI-HINT-001: names two stops of a day that share time, once per day, as a warning', () => {
|
||||
const hints = tripHints(
|
||||
input({
|
||||
stops: [
|
||||
stop(1, 1, { time: '10:00', end: '12:00' }),
|
||||
stop(2, 1, { time: '11:00', duration: 60 }),
|
||||
stop(3, 1, { time: '11:30', end: '12:30' }),
|
||||
],
|
||||
})
|
||||
);
|
||||
const overlaps = only(hints, 'overlap');
|
||||
expect(overlaps).toHaveLength(1);
|
||||
expect(overlaps[0]).toMatchObject({
|
||||
level: 'warn',
|
||||
dayId: 1,
|
||||
textKey: 'assistant.hint.overlap',
|
||||
promptKey: 'assistant.hint.overlap.prompt',
|
||||
params: { a: 'Stop 1', b: 'Stop 2' },
|
||||
focus: { dayId: 1, placeIds: [1001, 1002] },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-HINT-002: stops that only touch, two bare starts, and a stop timed apart by its own assignment do not overlap', () => {
|
||||
const overlaps = (stops: Assignment[]) => only(tripHints(input({ stops })), 'overlap').length;
|
||||
const ten = () => stop(1, 1, { time: '10:00', end: '11:00' });
|
||||
expect(overlaps([ten(), stop(2, 1, { time: '11:00' })])).toBe(0);
|
||||
expect(overlaps([stop(1, 1, { time: '10:00' }), stop(2, 1, { time: '10:00' })])).toBe(0);
|
||||
expect(overlaps([ten(), stop(2, 1, { time: '10:30', duration: 30 }, { assignment_time: '14:00' })])).toBe(0);
|
||||
expect(overlaps([ten(), stop(2, 1, { time: '10:30' })])).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-AI-HINT-003: collides a stop with a booking that keeps the traveller busy, but not with its own booking', () => {
|
||||
const lunch = booking({
|
||||
id: 7,
|
||||
title: 'Lunch',
|
||||
reservation_time: `${FRIDAY}T12:30`,
|
||||
reservation_end_time: `${FRIDAY}T14:00`,
|
||||
});
|
||||
const museum = stop(1, 1, { time: '13:00', duration: 60 });
|
||||
const [hint] = only(tripHints(input({ stops: [museum], reservations: [lunch] })), 'overlap');
|
||||
expect(hint.params).toEqual({ a: 'Lunch', b: 'Stop 1' });
|
||||
expect(hint.focus).toEqual({ dayId: 1, placeIds: [1001] });
|
||||
|
||||
const quiet = [
|
||||
{ ...lunch, assignment_id: 1 },
|
||||
{ ...lunch, type: 'parking' },
|
||||
{ ...lunch, status: 'cancelled' },
|
||||
{ ...lunch, ingest_state: 'staged' },
|
||||
{ ...lunch, reservation_time: '2026-06-13T12:30' },
|
||||
];
|
||||
for (const r of quiet) {
|
||||
expect(only(tripHints(input({ stops: [museum], reservations: [r] })), 'overlap')).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('opening hours', () => {
|
||||
it('FE-AI-HINT-004: warns about a place that is closed on the weekday of its day, and only on a dated day', () => {
|
||||
const closed = stop(1, 1, { place: { opening_hours: hours(4) } });
|
||||
const [hint] = tripHints(input({ stops: [closed] }));
|
||||
expect(hint).toMatchObject({
|
||||
rule: 'closed',
|
||||
level: 'warn',
|
||||
textKey: 'assistant.hint.closed',
|
||||
params: { place: 'Stop 1' },
|
||||
focus: { dayId: 1, placeIds: [1001] },
|
||||
});
|
||||
expect(tripHints(input({ days: [day(1, null)], stops: [closed] }))).toEqual([]);
|
||||
expect(tripHints(input({ days: [day(1, '2026-06-15')], stops: [closed] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-HINT-005: says when a visit starts before opening or runs past closing, and leaves hours past midnight alone', () => {
|
||||
const early = stop(1, 1, { time: '08:00', duration: 60, place: { opening_hours: hours(null) } });
|
||||
expect(tripHints(input({ stops: [early] }))[0]).toMatchObject({
|
||||
rule: 'outsideHours',
|
||||
textKey: 'assistant.hint.opensLate',
|
||||
promptKey: 'assistant.hint.outsideHours.prompt',
|
||||
params: { place: 'Stop 1', time: '09:00' },
|
||||
});
|
||||
const late = stop(1, 1, { time: '16:00', duration: 120, place: { opening_hours: hours(null) } });
|
||||
expect(tripHints(input({ stops: [late] }))[0]).toMatchObject({
|
||||
textKey: 'assistant.hint.closesEarly',
|
||||
params: { time: '17:00' },
|
||||
});
|
||||
const fits = stop(1, 1, { time: '10:00', duration: 120, place: { opening_hours: hours(null) } });
|
||||
const night = stop(1, 1, { time: '08:00', place: { opening_hours: hours(null, '20:00', '02:00') } });
|
||||
expect(tripHints(input({ stops: [fits] }))).toEqual([]);
|
||||
expect(tripHints(input({ stops: [night] }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gaps', () => {
|
||||
const morning = () => stop(1, 1, { time: '09:00', end: '10:00' });
|
||||
|
||||
it('FE-AI-HINT-006: points at three free hours between two timed stops, in the user clock', () => {
|
||||
const [hint] = tripHints(input({ stops: [morning(), stop(2, 1, { time: '13:00' })] }));
|
||||
expect(hint).toMatchObject({
|
||||
rule: 'gap',
|
||||
level: 'info',
|
||||
focus: { dayId: 1 },
|
||||
params: { count: 3, from: '10:00', to: '13:00' },
|
||||
});
|
||||
const twelve = tripHints(input({ stops: [morning(), stop(2, 1, { time: '13:00' })], is12h: true }))[0];
|
||||
expect(twelve.params.from).toBe(formatClockTime('10:00', true));
|
||||
expect(GAP_MIN_MINUTES).toBe(180);
|
||||
expect(tripHints(input({ stops: [morning(), stop(2, 1, { time: '12:59' })] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-HINT-007: a timed note, a booking in between or an unknown end fills the gap; a day without a date still has one', () => {
|
||||
const later = stop(2, 1, { time: '14:00' });
|
||||
const note = { id: 1, day_id: 1, text: 'Picnic', time: '11:30' };
|
||||
expect(tripHints(input({ stops: [morning(), later], dayNotes: { '1': [note] } }))).toEqual([]);
|
||||
const tour = booking({ type: 'tour', reservation_time: `${FRIDAY}T11:00` });
|
||||
expect(tripHints(input({ stops: [morning(), later], reservations: [tour] }))).toEqual([]);
|
||||
expect(tripHints(input({ stops: [stop(1, 1, { time: '09:00' }), later] }))).toEqual([]);
|
||||
const undated = tripHints(input({ days: [day(1, null)], stops: [morning(), later] }));
|
||||
expect(undated.map((h) => h.rule)).toEqual(['gap']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legs', () => {
|
||||
it('FE-AI-HINT-008: calls a walk of more than 4 km as the crow flies far, and a ride by bike only past 20 km', () => {
|
||||
const near = [stop(1, 1), stop(2, 1, { lat: 4 * KM - 1e-9 })];
|
||||
const far = [stop(1, 1), stop(2, 1, { lat: 4.01 * KM })];
|
||||
expect(LONG_LEG_KM.walking).toBe(4);
|
||||
expect(tripHints(input({ stops: near }))).toEqual([]);
|
||||
expect(tripHints(input({ stops: far }))[0]).toMatchObject({
|
||||
rule: 'longLeg',
|
||||
level: 'info',
|
||||
params: { from: 'Stop 1', to: 'Stop 2', distance: '4 km' },
|
||||
focus: { dayId: 1, placeIds: [1001, 1002] },
|
||||
});
|
||||
const byBike = [stop(1, 1, {}, { leg_transport_mode: 'cycling' }), stop(2, 1, { lat: 15 * KM })];
|
||||
expect(tripHints(input({ stops: byBike }))).toEqual([]);
|
||||
const byCar = [stop(1, 1), stop(2, 1, { lat: 100 * KM })];
|
||||
const driving = day(1, FRIDAY, { default_transport_mode: 'driving' });
|
||||
expect(tripHints(input({ days: [driving], stops: byCar }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-HINT-009: leaves out stops off the route, road trip and tour stops, and days with a flight', () => {
|
||||
const far = (extra: Partial<Assignment>, place: Partial<Place> = {}) => [
|
||||
stop(1, 1),
|
||||
stop(2, 1, { lat: 10 * KM, place }, extra),
|
||||
];
|
||||
expect(tripHints(input({ stops: far({ route_excluded: true }) }))).toEqual([]);
|
||||
expect(tripHints(input({ stops: far({ tour_place_id: 9 }) }))).toEqual([]);
|
||||
expect(tripHints(input({ stops: far({}, { stop_type: 'fuel' }) }))).toEqual([]);
|
||||
const flight = booking({ type: 'flight' });
|
||||
expect(tripHints(input({ stops: far({}), reservations: [flight] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-HINT-010: warns when the time between two stops is too short for the way, naming the minutes there are', () => {
|
||||
const first = stop(1, 1, { time: '10:00', end: '11:00' });
|
||||
const tight = tripHints(input({ stops: [first, stop(2, 1, { time: '11:30', lat: 3 * KM })] }));
|
||||
expect(tight[0]).toMatchObject({
|
||||
rule: 'tightLeg',
|
||||
level: 'warn',
|
||||
textKey: 'assistant.hint.tightLeg',
|
||||
params: { count: 30, from: 'Stop 1', to: 'Stop 2' },
|
||||
});
|
||||
expect(tripHints(input({ stops: [first, stop(2, 1, { time: '11:45', lat: 3 * KM })] }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nights', () => {
|
||||
const days = [day(1, '2026-06-11'), day(2, FRIDAY), day(3, '2026-06-13'), day(4, '2026-06-14')];
|
||||
|
||||
it('FE-AI-HINT-011: joins nights in a row without a stay into one hint, from their first day', () => {
|
||||
const hints = only(tripHints(input({ days, accommodations: [stay(1, 2)] })), 'noStay');
|
||||
expect(hints).toHaveLength(1);
|
||||
expect(hints[0]).toMatchObject({ dayId: 2, dayIndex: 1, params: { count: 2 }, focus: { dayId: 2 } });
|
||||
expect(only(tripHints(input({ days, accommodations: [stay(1, 4)] })), 'noStay')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-HINT-012: a ride through the night covers it; unknown stays, one-day trips and past nights say nothing', () => {
|
||||
const train = booking({ type: 'train', day_id: 2, end_day_id: 3 });
|
||||
const hints = only(tripHints(input({ days, accommodations: [stay(1, 2)], reservations: [train] })), 'noStay');
|
||||
expect(hints.map((h) => [h.dayId, h.params.count])).toEqual([[3, 1]]);
|
||||
expect(only(tripHints(input({ days, accommodations: null })), 'noStay')).toEqual([]);
|
||||
expect(only(tripHints(input({ days: [day(1)], accommodations: [] })), 'noStay')).toEqual([]);
|
||||
const past = [day(1, '2026-06-08'), day(2, '2026-06-09'), day(3, TODAY)];
|
||||
expect(only(tripHints(input({ days: past, accommodations: [] })), 'noStay')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookings without a day', () => {
|
||||
it('FE-AI-HINT-013: names one loose booking and its day, or counts several', () => {
|
||||
const ferry = booking({ id: 5, title: 'Ferry', day_id: null, reservation_time: `${FRIDAY}T08:00` });
|
||||
expect(tripHints(input({ reservations: [ferry] }))[0]).toMatchObject({
|
||||
rule: 'unplacedBooking',
|
||||
textKey: 'assistant.hint.unplacedBookingDay',
|
||||
dayId: 1,
|
||||
focus: { dayId: 1 },
|
||||
params: { title: 'Ferry' },
|
||||
});
|
||||
const undated = { ...ferry, reservation_time: null };
|
||||
expect(tripHints(input({ reservations: [undated] }))[0]).toMatchObject({
|
||||
textKey: 'assistant.hint.unplacedBooking',
|
||||
dayId: null,
|
||||
focus: null,
|
||||
});
|
||||
const two = tripHints(input({ reservations: [ferry, { ...undated, id: 6 }] }));
|
||||
expect(two[0]).toMatchObject({ textKey: 'assistant.hint.unplacedBookings', params: { count: 2 } });
|
||||
expect(two[0].id).toBe('unplacedBooking:trip:5,6');
|
||||
});
|
||||
|
||||
it('FE-AI-HINT-014: leaves hotels, cancelled, staged, past and stop-bound bookings alone', () => {
|
||||
const loose = { day_id: null, reservation_time: null };
|
||||
const quiet = [
|
||||
booking({ ...loose, type: 'hotel' }),
|
||||
booking({ ...loose, status: 'cancelled' }),
|
||||
booking({ ...loose, ingest_state: 'staged' }),
|
||||
booking({ ...loose, reservation_time: '2026-06-01T10:00' }),
|
||||
booking({ ...loose, assignment_id: 3 }),
|
||||
];
|
||||
expect(tripHints(input({ reservations: quiet }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the list', () => {
|
||||
it('FE-AI-HINT-015: puts warnings first, then by day; past days and stay stops are not read', () => {
|
||||
const hints = tripHints(
|
||||
input({
|
||||
days: [day(1, '2026-06-09'), day(2, '2026-06-11'), day(3, FRIDAY)],
|
||||
stops: [
|
||||
stop(1, 1, { time: '10:00', end: '12:00' }),
|
||||
stop(2, 1, { time: '11:00', end: '12:00' }),
|
||||
stop(3, 2, { time: '09:00', end: '10:00' }),
|
||||
stop(4, 2, { time: '14:00' }),
|
||||
stop(5, 3, { time: '10:00', end: '12:00' }),
|
||||
stop(6, 3, { time: '11:00', end: '12:00' }, { accommodation_id: 1 }),
|
||||
stop(7, 3, { time: '11:30', end: '13:00' }),
|
||||
],
|
||||
accommodations: [stay(1, 3)],
|
||||
})
|
||||
);
|
||||
expect(hints.map((h) => [h.rule, h.dayId])).toEqual([
|
||||
['overlap', 3],
|
||||
['gap', 2],
|
||||
]);
|
||||
expect(hints[0].params).toEqual({ a: 'Stop 5', b: 'Stop 7' });
|
||||
});
|
||||
|
||||
it('FE-AI-HINT-016: keeps an id while the plan stays the same and changes it with the times', () => {
|
||||
const plan = (end: string) =>
|
||||
input({ stops: [stop(1, 1, { time: '10:00', end }), stop(2, 1, { time: '11:00', end: '12:00' })] });
|
||||
expect(tripHints(plan('12:00'))[0].id).toBe(tripHints(plan('12:00'))[0].id);
|
||||
expect(tripHints(plan('12:00'))[0].id).not.toBe(tripHints(plan('11:30'))[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the clock', () => {
|
||||
it('FE-AI-HINT-017: reads HH:MM only, and a note by the time it starts with', () => {
|
||||
expect(clockMinutes('9:05')).toBe(545);
|
||||
expect(clockMinutes(' 23:59 ')).toBe(1439);
|
||||
for (const value of ['24:00', '10:60', '09:30:00', null]) expect(clockMinutes(value)).toBeNull();
|
||||
expect(noteMinutes('14:30 dinner')).toBe(870);
|
||||
expect(noteMinutes('dinner at 14:30')).toBeNull();
|
||||
expect(clockText(545)).toBe('09:05');
|
||||
});
|
||||
|
||||
it('FE-AI-HINT-018: reads the weekday from the date alone, Monday first', () => {
|
||||
expect(weekdayMondayFirst('2026-06-15')).toBe(0);
|
||||
expect(weekdayMondayFirst(`${FRIDAY}T23:30:00`)).toBe(4);
|
||||
expect(weekdayMondayFirst('2026-06-14')).toBe(6);
|
||||
expect(weekdayMondayFirst('soon')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-HINT-019: spans stops and bookings, and overlaps them', () => {
|
||||
expect(stopSpan(stop(1, 1, { time: '10:00', end: '09:00', duration: 30 }))).toEqual({ start: 600, end: 630 });
|
||||
expect(stopSpan(stop(1, 1, { time: '23:30', duration: 90 }))).toEqual({ start: 1410, end: 1440 });
|
||||
expect(stopSpan(stop(1, 1))).toBeNull();
|
||||
const overnight = booking({ reservation_time: `${FRIDAY}T22:00`, end_day_id: 2 });
|
||||
expect(bookingSpan(overnight, FRIDAY)).toEqual({ start: 1320, end: 1440 });
|
||||
expect(bookingSpan(booking({ reservation_time: '21:00' }), FRIDAY)).toEqual({ start: 1260, end: null });
|
||||
expect(bookingSpan(overnight, '2026-06-13')).toBeNull();
|
||||
expect(spansOverlap({ start: 600, end: null }, { start: 540, end: 660 })).toBe(true);
|
||||
expect(spansOverlap({ start: 540, end: 660 }, { start: 660, end: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
import { type AssistantFocus, parsePlaceHours, type PlaceOpeningHours, reservationTypeInfo } from '@trek/shared';
|
||||
|
||||
import type {
|
||||
Accommodation,
|
||||
Assignment,
|
||||
AssignmentsMap,
|
||||
Day,
|
||||
DayNotesMap,
|
||||
DistanceUnit,
|
||||
Place,
|
||||
Reservation,
|
||||
} from '../../types';
|
||||
import { formatClockTime, splitReservationDateTime } from '../../utils/formatters';
|
||||
import { haversineKm } from '../../utils/geo';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
import { dayBookings } from '../Planner/dayDetailModel';
|
||||
import {
|
||||
bookingSpan,
|
||||
clockMinutes,
|
||||
clockText,
|
||||
type DaySpan,
|
||||
noteMinutes,
|
||||
spanKey,
|
||||
spansOverlap,
|
||||
spanTouches,
|
||||
stopSpan,
|
||||
weekdayMondayFirst,
|
||||
} from './assistantHintsTime';
|
||||
|
||||
/**
|
||||
* The plan hints the empty chat offers: things in the trip worth a second
|
||||
* look, worked out from the data the client already holds. No model and no
|
||||
* request is involved, so they cost nothing and appear at once. Each hint
|
||||
* carries a short line, the question a tap sends, and the day or places that
|
||||
* question is about.
|
||||
*
|
||||
* A false alarm costs more than a missed one. Only rules on hard data (times,
|
||||
* stored opening hours) warn; whatever rests on an estimate is a quiet note.
|
||||
*/
|
||||
|
||||
/** Free time between two stops that counts as a gap. */
|
||||
export const GAP_MIN_MINUTES = 180;
|
||||
/** Straight-line distance between two stops that counts as far, by how the leg is travelled. */
|
||||
export const LONG_LEG_KM = { walking: 4, cycling: 20, other: 150 } as const;
|
||||
/** Rough speed of a leg, to tell whether the time between two stops is enough. */
|
||||
const LEG_SPEED_KMH = { walking: 4.5, cycling: 15, other: 50 } as const;
|
||||
/** How much longer the way is than the straight line. */
|
||||
const DETOUR_FACTOR = 1.3;
|
||||
/** How far the estimate may run past the time available before it is worth a word. */
|
||||
const TIGHT_LEG_SLACK_MINUTES = 10;
|
||||
|
||||
export type HintRule =
|
||||
'overlap' | 'closed' | 'outsideHours' | 'tightLeg' | 'gap' | 'longLeg' | 'noStay' | 'unplacedBooking';
|
||||
|
||||
const RULE_ORDER: readonly HintRule[] = [
|
||||
'overlap',
|
||||
'closed',
|
||||
'outsideHours',
|
||||
'tightLeg',
|
||||
'gap',
|
||||
'longLeg',
|
||||
'noStay',
|
||||
'unplacedBooking',
|
||||
];
|
||||
|
||||
const WARNS: ReadonlySet<HintRule> = new Set(['overlap', 'closed', 'outsideHours', 'tightLeg']);
|
||||
|
||||
/** Bookings that keep the traveller busy at their time, so a stop at the same time collides with them. */
|
||||
const OCCUPYING_TYPES: ReadonlySet<string> = new Set(['restaurant', 'event', 'tour', 'activity']);
|
||||
|
||||
export interface AssistantHint {
|
||||
/** Stable while the situation stays the same; a changed time or stop makes a new hint. */
|
||||
id: string;
|
||||
rule: HintRule;
|
||||
level: 'warn' | 'info';
|
||||
/** Position of the day in the trip, for the order; null for the trip as a whole. */
|
||||
dayIndex: number | null;
|
||||
/** The day the line names as {day}; the caller words it. */
|
||||
dayId: number | null;
|
||||
/** What the question is about, set on the chat before it is sent. */
|
||||
focus: AssistantFocus | null;
|
||||
textKey: string;
|
||||
promptKey: string;
|
||||
params: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface TripHintsInput {
|
||||
/** The user's calendar date, YYYY-MM-DD; earlier days are left alone. */
|
||||
today: string;
|
||||
days: Day[];
|
||||
assignments: AssignmentsMap;
|
||||
places: Place[];
|
||||
reservations: Reservation[];
|
||||
dayNotes: DayNotesMap;
|
||||
/** The trip's stays; null while they are not known, which keeps the night rule quiet. */
|
||||
accommodations: Accommodation[] | null;
|
||||
distanceUnit: DistanceUnit;
|
||||
is12h: boolean;
|
||||
}
|
||||
|
||||
interface Stop {
|
||||
assignment: Assignment;
|
||||
placeId: number;
|
||||
name: string;
|
||||
span: DaySpan | null;
|
||||
hours: PlaceOpeningHours | null;
|
||||
}
|
||||
|
||||
interface TimedBooking {
|
||||
reservation: Reservation;
|
||||
span: DaySpan;
|
||||
}
|
||||
|
||||
interface PlannedDay {
|
||||
day: Day;
|
||||
index: number;
|
||||
stops: Stop[];
|
||||
/** The day's bookings with a time, the ones that occupy the traveller. */
|
||||
bookings: TimedBooking[];
|
||||
hasCarrier: boolean;
|
||||
noteTimes: number[];
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
input: TripHintsInput;
|
||||
clock: (minutes: number) => string;
|
||||
}
|
||||
|
||||
function isLive(r: Reservation): boolean {
|
||||
return r.status !== 'cancelled' && r.ingest_state !== 'staged';
|
||||
}
|
||||
|
||||
function isCarrier(r: Reservation): boolean {
|
||||
return reservationTypeInfo(r.type)?.isCarrier === true;
|
||||
}
|
||||
|
||||
function hint(
|
||||
rule: HintRule,
|
||||
key: string,
|
||||
at: { day: Day; index: number } | null,
|
||||
fields: Pick<AssistantHint, 'focus' | 'textKey' | 'promptKey' | 'params'>
|
||||
): AssistantHint {
|
||||
return {
|
||||
id: `${rule}:${at?.day.id ?? 'trip'}:${key}`,
|
||||
rule,
|
||||
level: WARNS.has(rule) ? 'warn' : 'info',
|
||||
dayIndex: at?.index ?? null,
|
||||
dayId: at?.day.id ?? null,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function planDay(day: Day, index: number, input: TripHintsInput, placesById: Map<number, Place>): PlannedDay {
|
||||
const all = (input.assignments[String(day.id)] ?? []).slice().sort((a, b) => a.order_index - b.order_index);
|
||||
const toStop = (a: Assignment): Stop => ({
|
||||
assignment: a,
|
||||
placeId: a.place_id,
|
||||
name: a.place.name,
|
||||
span: stopSpan(a),
|
||||
hours: parsePlaceHours(placesById.get(a.place_id)?.opening_hours),
|
||||
});
|
||||
// A stay's own stop is the overnight block, not a visit.
|
||||
const stops = all.filter((a) => a.accommodation_id == null).map(toStop);
|
||||
const booked = dayBookings(day.id, all, input.reservations.filter(isLive));
|
||||
const bookings: TimedBooking[] = [];
|
||||
for (const reservation of booked) {
|
||||
if (!isCarrier(reservation) && !OCCUPYING_TYPES.has(reservation.type)) continue;
|
||||
const span = bookingSpan(reservation, day.date);
|
||||
if (span) bookings.push({ reservation, span });
|
||||
}
|
||||
const noteTimes = (input.dayNotes[String(day.id)] ?? [])
|
||||
.map((n) => noteMinutes(n.time))
|
||||
.filter((m): m is number => m !== null);
|
||||
return { day, index, stops, bookings, hasCarrier: booked.some(isCarrier), noteTimes };
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
key: string;
|
||||
name: string;
|
||||
span: DaySpan;
|
||||
placeId: number | null;
|
||||
assignmentId: number | null;
|
||||
isStop: boolean;
|
||||
}
|
||||
|
||||
/** A booking hung on a stop, or made for its place, is that stop: the two cannot collide. */
|
||||
function sameVisit(a: Slot, b: Slot): boolean {
|
||||
return (
|
||||
(a.assignmentId !== null && a.assignmentId === b.assignmentId) || (a.placeId !== null && a.placeId === b.placeId)
|
||||
);
|
||||
}
|
||||
|
||||
function overlapHint(pd: PlannedDay): AssistantHint | null {
|
||||
const slots: Slot[] = [];
|
||||
for (const s of pd.stops) {
|
||||
if (s.span) {
|
||||
slots.push({
|
||||
key: `a${s.assignment.id}`,
|
||||
name: s.name,
|
||||
span: s.span,
|
||||
placeId: s.placeId,
|
||||
assignmentId: s.assignment.id,
|
||||
isStop: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const b of pd.bookings) {
|
||||
slots.push({
|
||||
key: `r${b.reservation.id}`,
|
||||
name: b.reservation.title,
|
||||
span: b.span,
|
||||
placeId: b.reservation.place_id ?? null,
|
||||
assignmentId: b.reservation.assignment_id ?? null,
|
||||
isStop: false,
|
||||
});
|
||||
}
|
||||
slots.sort((a, b) => a.span.start - b.span.start);
|
||||
for (let i = 0; i < slots.length; i++) {
|
||||
for (let j = i + 1; j < slots.length; j++) {
|
||||
const [a, b] = [slots[i], slots[j]];
|
||||
if (sameVisit(a, b) || !spansOverlap(a.span, b.span)) continue;
|
||||
const placeIds = [a, b].filter((s) => s.isStop && s.placeId !== null).map((s) => s.placeId as number);
|
||||
return hint('overlap', `${a.key}@${spanKey(a.span)},${b.key}@${spanKey(b.span)}`, pd, {
|
||||
focus: placeIds.length > 0 ? { dayId: pd.day.id, placeIds } : { dayId: pd.day.id },
|
||||
textKey: 'assistant.hint.overlap',
|
||||
promptKey: 'assistant.hint.overlap.prompt',
|
||||
params: { a: a.name, b: b.name },
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hoursHint(pd: PlannedDay, stop: Stop, weekday: number, ctx: Ctx): AssistantHint | null {
|
||||
const hours = stop.hours?.[weekday];
|
||||
if (!hours) return null;
|
||||
const focus = { dayId: pd.day.id, placeIds: [stop.placeId] };
|
||||
if (hours.closed) {
|
||||
return hint('closed', `p${stop.placeId}`, pd, {
|
||||
focus,
|
||||
textKey: 'assistant.hint.closed',
|
||||
promptKey: 'assistant.hint.closed.prompt',
|
||||
params: { place: stop.name },
|
||||
});
|
||||
}
|
||||
const open = clockMinutes(hours.open);
|
||||
const close = clockMinutes(hours.close);
|
||||
// Hours past midnight are left alone: which day they belong to is a guess.
|
||||
if (!stop.span || open === null || close === null || close <= open) return null;
|
||||
const { start } = stop.span;
|
||||
const end = stop.span.end ?? start;
|
||||
const key = `p${stop.placeId}@${spanKey(stop.span)}`;
|
||||
if (start < open) {
|
||||
return hint('outsideHours', key, pd, {
|
||||
focus,
|
||||
textKey: 'assistant.hint.opensLate',
|
||||
promptKey: 'assistant.hint.outsideHours.prompt',
|
||||
params: { place: stop.name, time: ctx.clock(open) },
|
||||
});
|
||||
}
|
||||
if (end > close || start >= close) {
|
||||
return hint('outsideHours', key, pd, {
|
||||
focus,
|
||||
textKey: 'assistant.hint.closesEarly',
|
||||
promptKey: 'assistant.hint.outsideHours.prompt',
|
||||
params: { place: stop.name, time: ctx.clock(close) },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hoursHints(pd: PlannedDay, ctx: Ctx): AssistantHint[] {
|
||||
const weekday = weekdayMondayFirst(pd.day.date);
|
||||
if (weekday === null) return [];
|
||||
return pd.stops.map((s) => hoursHint(pd, s, weekday, ctx)).filter((h): h is AssistantHint => h !== null);
|
||||
}
|
||||
|
||||
/** Whether a booking or a timed note already fills the free time. */
|
||||
function gapFilled(pd: PlannedDay, from: number, to: number): boolean {
|
||||
return pd.bookings.some((b) => spanTouches(b.span, from, to)) || pd.noteTimes.some((t) => t > from && t < to);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first long stretch with nothing planned between two timed stops. A free
|
||||
* morning or evening is not a gap, and a stop whose end is not known leaves
|
||||
* the time after it unknown too.
|
||||
*/
|
||||
function gapHint(pd: PlannedDay, ctx: Ctx): AssistantHint | null {
|
||||
const timed = pd.stops.filter((s) => s.span).sort((a, b) => (a.span as DaySpan).start - (b.span as DaySpan).start);
|
||||
let reach: number | null = null;
|
||||
for (let k = 0; k < timed.length; k++) {
|
||||
const span = timed[k].span as DaySpan;
|
||||
if (k > 0 && reach !== null && span.start - reach >= GAP_MIN_MINUTES && !gapFilled(pd, reach, span.start)) {
|
||||
return hint('gap', `${reach}-${span.start}`, pd, {
|
||||
focus: { dayId: pd.day.id },
|
||||
textKey: 'assistant.hint.gap',
|
||||
promptKey: 'assistant.hint.gap.prompt',
|
||||
params: {
|
||||
count: Math.floor((span.start - reach) / 60),
|
||||
from: ctx.clock(reach),
|
||||
to: ctx.clock(span.start),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (span.end === null) reach = reach !== null && reach > span.start ? reach : null;
|
||||
else reach = Math.max(reach ?? span.end, span.end);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type LegClass = keyof typeof LONG_LEG_KM;
|
||||
|
||||
function legClass(mode: string | null | undefined): LegClass {
|
||||
if (!mode || mode === 'walking') return 'walking';
|
||||
return mode === 'cycling' ? 'cycling' : 'other';
|
||||
}
|
||||
|
||||
/** A stop on the day's route: placed on the map, and not one a road trip or a tour lays out by itself. */
|
||||
function routable(s: Stop): boolean {
|
||||
const a = s.assignment;
|
||||
return (
|
||||
a.place.lat != null && a.place.lng != null && !a.route_excluded && !a.place.stop_type && a.tour_place_id == null
|
||||
);
|
||||
}
|
||||
|
||||
function tightLegHint(pd: PlannedDay, from: Stop, to: Stop, km: number, cls: LegClass): AssistantHint | null {
|
||||
const leaves = from.span?.end;
|
||||
const arrives = to.span?.start;
|
||||
if (leaves == null || arrives == null) return null;
|
||||
const available = arrives - leaves;
|
||||
const needed = ((km * DETOUR_FACTOR) / LEG_SPEED_KMH[cls]) * 60;
|
||||
// Less than nothing is an overlap, which says so itself.
|
||||
if (available < 0 || needed <= available + TIGHT_LEG_SLACK_MINUTES) return null;
|
||||
return hint('tightLeg', `a${from.assignment.id}-a${to.assignment.id}@${leaves}-${arrives}:${cls}`, pd, {
|
||||
focus: { dayId: pd.day.id, placeIds: [from.placeId, to.placeId] },
|
||||
textKey: 'assistant.hint.tightLeg',
|
||||
promptKey: 'assistant.hint.tightLeg.prompt',
|
||||
params: { count: available, from: from.name, to: to.name },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The legs between consecutive stops, by straight line: one that is short of
|
||||
* time, and one that is far for the way it is travelled. A day with a flight,
|
||||
* train or ferry is left alone, since the distance is the point of it.
|
||||
*/
|
||||
function legHints(pd: PlannedDay, ctx: Ctx): AssistantHint[] {
|
||||
if (pd.hasCarrier) return [];
|
||||
const route = pd.stops.filter(routable);
|
||||
let tight: AssistantHint | null = null;
|
||||
let long: AssistantHint | null = null;
|
||||
for (let k = 1; k < route.length; k++) {
|
||||
const [from, to] = [route[k - 1], route[k]];
|
||||
const cls = legClass(from.assignment.leg_transport_mode ?? pd.day.default_transport_mode);
|
||||
const km = haversineKm(
|
||||
{ lat: from.assignment.place.lat as number, lng: from.assignment.place.lng as number },
|
||||
{ lat: to.assignment.place.lat as number, lng: to.assignment.place.lng as number }
|
||||
);
|
||||
const tooTight: AssistantHint | null = tight ? null : tightLegHint(pd, from, to, km, cls);
|
||||
if (tooTight) {
|
||||
tight = tooTight;
|
||||
continue;
|
||||
}
|
||||
if (!long && km > LONG_LEG_KM[cls]) {
|
||||
long = hint('longLeg', `a${from.assignment.id}-a${to.assignment.id}:${cls}`, pd, {
|
||||
focus: { dayId: pd.day.id, placeIds: [from.placeId, to.placeId] },
|
||||
textKey: 'assistant.hint.longLeg',
|
||||
promptKey: 'assistant.hint.longLeg.prompt',
|
||||
params: { from: from.name, to: to.name, distance: formatDistance(km, ctx.input.distanceUnit) },
|
||||
});
|
||||
}
|
||||
}
|
||||
return [tight, long].filter((h): h is AssistantHint => h !== null);
|
||||
}
|
||||
|
||||
/** Marks the nights from the day at `start` up to the one before `end`, by their place in the trip. */
|
||||
function cover(nights: boolean[], start: number | undefined, end: number | undefined): void {
|
||||
if (start === undefined || end === undefined) return;
|
||||
for (let i = Math.max(start, 0); i < Math.min(end, nights.length); i++) nights[i] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nights without a bed: the night after each day but the last, covered by a
|
||||
* stay or by a ride through it (night train, ferry, flight). Nights in a row
|
||||
* make one hint, and nights already past count as covered.
|
||||
*/
|
||||
function noStayHints(input: TripHintsInput): AssistantHint[] {
|
||||
const { days, accommodations } = input;
|
||||
if (!accommodations || days.length < 2) return [];
|
||||
const indexOf = new Map(days.map((d, i) => [d.id, i]));
|
||||
const covered = days.slice(0, -1).map((d) => !!d.date && d.date < input.today);
|
||||
for (const a of accommodations) cover(covered, indexOf.get(a.start_day_id), indexOf.get(a.end_day_id));
|
||||
for (const r of input.reservations) {
|
||||
if (!isLive(r) || !isCarrier(r) || r.day_id == null || r.end_day_id == null) continue;
|
||||
cover(covered, indexOf.get(r.day_id), indexOf.get(r.end_day_id));
|
||||
}
|
||||
const hints: AssistantHint[] = [];
|
||||
for (let i = 0; i < covered.length; i++) {
|
||||
if (covered[i]) continue;
|
||||
let nights = 1;
|
||||
while (i + nights < covered.length && !covered[i + nights]) nights++;
|
||||
const at = { day: days[i], index: i };
|
||||
const night = hint('noStay', String(nights), at, {
|
||||
focus: { dayId: at.day.id },
|
||||
textKey: 'assistant.hint.noStay',
|
||||
promptKey: 'assistant.hint.noStay.prompt',
|
||||
params: { count: nights },
|
||||
});
|
||||
hints.push(night);
|
||||
i += nights - 1;
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings that hang on no day, which the day view therefore never shows. One
|
||||
* names itself, and its day when its date is a day of the trip; several are
|
||||
* counted. A booking already behind the user is left alone.
|
||||
*/
|
||||
function unplacedHint(input: TripHintsInput): AssistantHint | null {
|
||||
const loose = input.reservations
|
||||
.filter((r) => isLive(r) && r.type !== 'hotel' && r.day_id == null && r.assignment_id == null)
|
||||
.map((r) => ({ r, date: splitReservationDateTime(r.reservation_time).date }))
|
||||
.filter(({ date }) => !date || date >= input.today);
|
||||
if (loose.length === 0) return null;
|
||||
const key = loose
|
||||
.map(({ r }) => r.id)
|
||||
.sort((a, b) => a - b)
|
||||
.join(',');
|
||||
const fields = { promptKey: 'assistant.hint.unplacedBooking.prompt' };
|
||||
if (loose.length > 1) {
|
||||
return hint('unplacedBooking', key, null, {
|
||||
...fields,
|
||||
focus: null,
|
||||
textKey: 'assistant.hint.unplacedBookings',
|
||||
params: { count: loose.length },
|
||||
});
|
||||
}
|
||||
const { r, date } = loose[0];
|
||||
const index = date ? input.days.findIndex((d) => d.date === date) : -1;
|
||||
if (index < 0) {
|
||||
return hint('unplacedBooking', key, null, {
|
||||
...fields,
|
||||
focus: null,
|
||||
textKey: 'assistant.hint.unplacedBooking',
|
||||
params: { title: r.title },
|
||||
});
|
||||
}
|
||||
const at = { day: input.days[index], index };
|
||||
return hint('unplacedBooking', key, at, {
|
||||
...fields,
|
||||
focus: { dayId: at.day.id },
|
||||
textKey: 'assistant.hint.unplacedBookingDay',
|
||||
params: { title: r.title },
|
||||
});
|
||||
}
|
||||
|
||||
function byWeight(a: AssistantHint, b: AssistantHint): number {
|
||||
if (a.level !== b.level) return a.level === 'warn' ? -1 : 1;
|
||||
const ai = a.dayIndex ?? Number.MAX_SAFE_INTEGER;
|
||||
const bi = b.dayIndex ?? Number.MAX_SAFE_INTEGER;
|
||||
if (ai !== bi) return ai - bi;
|
||||
return RULE_ORDER.indexOf(a.rule) - RULE_ORDER.indexOf(b.rule);
|
||||
}
|
||||
|
||||
/** Every hint for the trip, warnings first, then by day and rule. */
|
||||
export function tripHints(input: TripHintsInput): AssistantHint[] {
|
||||
const ctx: Ctx = { input, clock: (m) => formatClockTime(clockText(m), input.is12h) };
|
||||
const placesById = new Map(input.places.map((p) => [p.id, p]));
|
||||
const hints: AssistantHint[] = [];
|
||||
input.days.forEach((day, index) => {
|
||||
if (day.date && day.date < input.today) return;
|
||||
const pd = planDay(day, index, input, placesById);
|
||||
const overlap = overlapHint(pd);
|
||||
const gap = gapHint(pd, ctx);
|
||||
if (overlap) hints.push(overlap);
|
||||
hints.push(...hoursHints(pd, ctx), ...legHints(pd, ctx));
|
||||
if (gap) hints.push(gap);
|
||||
});
|
||||
hints.push(...noStayHints(input));
|
||||
const unplaced = unplacedHint(input);
|
||||
if (unplaced) hints.push(unplaced);
|
||||
return hints.sort(byWeight);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Assignment, Reservation } from '../../types';
|
||||
import { splitReservationDateTime } from '../../utils/formatters';
|
||||
|
||||
/**
|
||||
* The clock arithmetic behind the plan hints: times of day as minutes since
|
||||
* midnight, and the stretch of the day a stop or a booking takes. A value
|
||||
* that is not a plain HH:MM reads as no time at all, so a rule that needs it
|
||||
* stays quiet rather than guessing.
|
||||
*/
|
||||
|
||||
const MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
/** A stretch of one day in minutes since midnight; `end` is null when only the start is known. */
|
||||
export interface DaySpan {
|
||||
start: number;
|
||||
end: number | null;
|
||||
}
|
||||
|
||||
function minutesOf(match: RegExpExecArray | null): number | null {
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
return hours < 24 && minutes < 60 ? hours * 60 + minutes : null;
|
||||
}
|
||||
|
||||
/** "09:30" as 570; anything that is not HH:MM (or H:MM) is null. */
|
||||
export function clockMinutes(value: string | null | undefined): number | null {
|
||||
return minutesOf(/^(\d{1,2}):(\d{2})$/.exec(value?.trim() ?? ''));
|
||||
}
|
||||
|
||||
/** The time a day note starts with, if it starts with one: the note is free text. */
|
||||
export function noteMinutes(value: string | null | undefined): number | null {
|
||||
return minutesOf(/^(\d{1,2}):(\d{2})(?!\d)/.exec(value?.trim() ?? ''));
|
||||
}
|
||||
|
||||
/** 570 as "09:30". */
|
||||
export function clockText(minutes: number): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The weekday of a calendar date, Monday first like stored opening hours.
|
||||
* Read in UTC, so the zone of the device cannot move the date a day.
|
||||
*/
|
||||
export function weekdayMondayFirst(iso: string | null | undefined): number | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso ?? '');
|
||||
if (!match) return null;
|
||||
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
||||
return Number.isNaN(date.getTime()) ? null : (date.getUTCDay() + 6) % 7;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a stop takes place: its own time on this day before the place's, an
|
||||
* end before a duration. An end at or before the start says nothing, and a
|
||||
* duration never runs past midnight.
|
||||
*/
|
||||
export function stopSpan(assignment: Assignment): DaySpan | null {
|
||||
const start = clockMinutes(assignment.assignment_time ?? assignment.place.place_time);
|
||||
if (start === null) return null;
|
||||
const end = clockMinutes(assignment.assignment_end_time ?? assignment.place.end_time);
|
||||
if (end !== null && end > start) return { start, end };
|
||||
const duration = assignment.place.duration_minutes ?? 0;
|
||||
return { start, end: duration > 0 ? Math.min(start + duration, MINUTES_PER_DAY) : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* When a booking takes place on its day. A time written for another date than
|
||||
* the day's says nothing about it, and a booking that ends on a later day runs
|
||||
* to midnight.
|
||||
*/
|
||||
export function bookingSpan(reservation: Reservation, dayDate: string | null | undefined): DaySpan | null {
|
||||
const begins = splitReservationDateTime(reservation.reservation_time);
|
||||
if (begins.date && dayDate && begins.date !== dayDate) return null;
|
||||
const start = clockMinutes(begins.time);
|
||||
if (start === null) return null;
|
||||
if (reservation.end_day_id != null && reservation.end_day_id !== reservation.day_id) {
|
||||
return { start, end: MINUTES_PER_DAY };
|
||||
}
|
||||
const end = clockMinutes(splitReservationDateTime(reservation.reservation_end_time).time);
|
||||
return { start, end: end !== null && end > start ? end : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two stretches share time. Two stretches overlap when each starts
|
||||
* before the other ends; a bare start overlaps only when it lies truly inside
|
||||
* a stretch, and two bare starts never do.
|
||||
*/
|
||||
export function spansOverlap(a: DaySpan, b: DaySpan): boolean {
|
||||
if (a.end !== null && b.end !== null) return a.start < b.end && b.start < a.end;
|
||||
if (a.end !== null) return a.start < b.start && b.start < a.end;
|
||||
if (b.end !== null) return b.start < a.start && a.start < b.end;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether a stretch, or a bare start, falls into the open window from `from` to `to`. */
|
||||
export function spanTouches(span: DaySpan, from: number, to: number): boolean {
|
||||
return span.start < to && (span.end ?? span.start) > from;
|
||||
}
|
||||
|
||||
/** A span as it goes into a hint's id, so a hint about other times is a new hint. */
|
||||
export function spanKey(span: DaySpan): string {
|
||||
return `${span.start}-${span.end ?? ''}`;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* FE-AI-MENTION-MODEL-001 to FE-AI-MENTION-MODEL-008
|
||||
*
|
||||
* What `@` offers and writes, without React: the entries per kind, the search
|
||||
* across them, the mention behind a pick, and the text that carries it.
|
||||
*/
|
||||
import type { TriggerItem } from '@assistant-ui/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
|
||||
import {
|
||||
mentionGroups,
|
||||
mentionLabel,
|
||||
mentionOf,
|
||||
searchMentions,
|
||||
splitMentions,
|
||||
withMention,
|
||||
} from './assistantMentionModel';
|
||||
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
return key === 'dayplan.dayN' ? `Day ${params?.n}` : key;
|
||||
}
|
||||
|
||||
const SOURCES = {
|
||||
days: [buildDay({ id: 1, title: 'Arrival', date: '2026-11-02' }), buildDay({ id: 2, title: null, date: null })],
|
||||
places: [
|
||||
buildPlace({ id: 10, name: 'Louvre' }),
|
||||
buildPlace({ id: 11, name: 'Musée du Louvre shop' }),
|
||||
buildPlace({ id: 12, name: 'Bouillon Pigalle' }),
|
||||
buildPlace({ id: 13, name: ' ' }),
|
||||
],
|
||||
reservations: [buildReservation({ id: 20, title: 'Louvre tickets', day_id: 1 })],
|
||||
};
|
||||
|
||||
describe('assistantMentionModel', () => {
|
||||
it('FE-AI-MENTION-MODEL-001: lists days by their day-list name, places by name, bookings with their day', () => {
|
||||
const groups = mentionGroups(SOURCES, t, 'en');
|
||||
expect(groups.day.map((i) => i.label)).toEqual(['Arrival', 'Day 2']);
|
||||
// A titled day adds its date; an untitled one says nothing more.
|
||||
expect(groups.day[0].description).toBeTruthy();
|
||||
expect(groups.day[1].description).toBeUndefined();
|
||||
// A place without a name has nothing to write into the text and is left out.
|
||||
expect(groups.place.map((i) => i.id)).toEqual(['place:10', 'place:11', 'place:12']);
|
||||
expect(groups.reservation[0]).toMatchObject({
|
||||
label: 'Louvre tickets',
|
||||
description: 'Arrival',
|
||||
type: 'reservation',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-MODEL-002: search ranks a label that starts with the query before a word inside one', () => {
|
||||
const groups = mentionGroups(SOURCES, t, 'en');
|
||||
expect(searchMentions(groups, 'louv').map((i) => i.label)).toEqual([
|
||||
'Louvre',
|
||||
'Louvre tickets',
|
||||
'Musée du Louvre shop',
|
||||
]);
|
||||
expect(searchMentions(groups, 'llon').map((i) => i.label)).toEqual(['Bouillon Pigalle']);
|
||||
expect(searchMentions(groups, 'zzz')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-MODEL-003: search shows at most eight hits', () => {
|
||||
const places = Array.from({ length: 12 }, (_, i) => buildPlace({ id: 100 + i, name: `Café ${i}` }));
|
||||
expect(searchMentions(mentionGroups({ ...SOURCES, places }, t, 'en'), 'caf')).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-MODEL-004: a pick reads back as its mention, anything else as nothing', () => {
|
||||
const [louvre] = mentionGroups(SOURCES, t, 'en').place;
|
||||
expect(mentionOf(louvre)).toEqual({ kind: 'place', id: 10, label: 'Louvre' });
|
||||
const foreign: TriggerItem = { id: 'x', type: 'command', label: 'Run' };
|
||||
expect(mentionOf(foreign)).toBeNull();
|
||||
expect(mentionOf({ ...louvre, metadata: { id: 'ten' } })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-MODEL-005: labels are one trimmed line the server keeps whole', () => {
|
||||
expect(mentionLabel(' Le Train\nBleu ')).toBe('Le Train Bleu');
|
||||
expect(mentionLabel('x'.repeat(200))).toHaveLength(120);
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-MODEL-006: writing @label keeps the rest of the text, once', () => {
|
||||
expect(withMention('', 'Louvre')).toBe('@Louvre ');
|
||||
expect(withMention('Lunch near', 'Louvre')).toBe('Lunch near @Louvre ');
|
||||
expect(withMention('Lunch near ', 'Louvre')).toBe('Lunch near @Louvre ');
|
||||
expect(withMention('Near @Louvre ', 'Louvre')).toBe('Near @Louvre ');
|
||||
// A longer label that starts the same is another mention.
|
||||
expect(withMention('@Romeo ', 'Rome')).toBe('@Romeo @Rome ');
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-MODEL-008: a sent question splits into text and its mentions, the longest label first', () => {
|
||||
expect(splitMentions('Is @Louvre tickets still valid at @Louvre?', ['Louvre', 'Louvre tickets'])).toEqual([
|
||||
{ text: 'Is ', at: 0, mention: false },
|
||||
{ text: '@Louvre tickets', at: 3, mention: true },
|
||||
{ text: ' still valid at ', at: 18, mention: false },
|
||||
{ text: '@Louvre', at: 34, mention: true },
|
||||
{ text: '?', at: 41, mention: false },
|
||||
]);
|
||||
expect(splitMentions('mail me @ home @Lou', ['Louvre'])).toEqual([
|
||||
{ text: 'mail me @ home @Lou', at: 0, mention: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { DirectiveFormatter, TriggerItem } from '@assistant-ui/react';
|
||||
import { ASSISTANT_MENTION_KINDS, type AssistantMention } from '@trek/shared';
|
||||
|
||||
import type { AssistantMentionItem } from '../../store/assistantStore';
|
||||
import type { Day, Place, Reservation } from '../../types';
|
||||
import { dayDate, dayLabel } from '../../utils/dayLabel';
|
||||
|
||||
/**
|
||||
* What `@` in the composer offers and what it writes: the trip's days, places
|
||||
* and bookings as entries of the popover, `@label` in the text for a pick, and
|
||||
* the way back from that text to the mentions a question still carries.
|
||||
*/
|
||||
|
||||
export type MentionKind = AssistantMention['kind'];
|
||||
|
||||
/** The heading of each list `@` opens. */
|
||||
export const MENTION_KINDS: Record<MentionKind, { labelKey: string }> = {
|
||||
day: { labelKey: 'assistant.mention.days' },
|
||||
place: { labelKey: 'assistant.mention.places' },
|
||||
reservation: { labelKey: 'assistant.mention.bookings' },
|
||||
};
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** The longest label the server keeps for a mention. */
|
||||
const LABEL_MAX = 120;
|
||||
/** Hits shown while typing after `@`; the list stays one short glance. */
|
||||
const SEARCH_MAX = 8;
|
||||
|
||||
interface MentionSources {
|
||||
days: readonly Day[];
|
||||
places: readonly Place[];
|
||||
reservations: readonly Reservation[];
|
||||
}
|
||||
|
||||
type MentionGroups = Record<MentionKind, TriggerItem[]>;
|
||||
|
||||
/** A label as it goes into the text: one line, trimmed, never longer than the server keeps. */
|
||||
export function mentionLabel(name: string): string {
|
||||
return name.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX);
|
||||
}
|
||||
|
||||
function entry(kind: MentionKind, id: number, name: string, description?: string | null): TriggerItem[] {
|
||||
const label = mentionLabel(name);
|
||||
if (!label) return [];
|
||||
return [{ id: `${kind}:${id}`, type: kind, label, ...(description ? { description } : {}), metadata: { id } }];
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries `@` lists, per kind. A day reads as it does in the day list; a
|
||||
* titled day adds its date, and a booking the day it is on.
|
||||
*/
|
||||
export function mentionGroups(sources: MentionSources, t: Translate, locale: string): MentionGroups {
|
||||
const dayNames = new Map(sources.days.map((d, i) => [d.id, dayLabel(d, i, t, locale)]));
|
||||
return {
|
||||
day: sources.days.flatMap((d) =>
|
||||
entry('day', d.id, dayNames.get(d.id) ?? '', d.title && d.date ? dayDate(d.date, locale) : null)
|
||||
),
|
||||
place: sources.places.flatMap((p) => entry('place', p.id, p.name)),
|
||||
reservation: sources.reservations.flatMap((r) =>
|
||||
entry('reservation', r.id, r.title, r.day_id ? dayNames.get(r.day_id) : null)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 0 for a label that starts with the query, 1 for one with a word that does, 2 for the rest. */
|
||||
function rankOf(label: string, at: number): number {
|
||||
if (at === 0) return 0;
|
||||
return /\s/u.test(label.charAt(at - 1)) ? 1 : 2;
|
||||
}
|
||||
|
||||
/** The entries whose label holds the query, across all kinds, the best matches first. */
|
||||
export function searchMentions(groups: MentionGroups, query: string): TriggerItem[] {
|
||||
const q = query.toLocaleLowerCase();
|
||||
const hits: { item: TriggerItem; rank: number }[] = [];
|
||||
for (const kind of ASSISTANT_MENTION_KINDS) {
|
||||
for (const item of groups[kind]) {
|
||||
const label = item.label.toLocaleLowerCase();
|
||||
const at = label.indexOf(q);
|
||||
if (at >= 0) hits.push({ item, rank: rankOf(label, at) });
|
||||
}
|
||||
}
|
||||
return hits
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
.slice(0, SEARCH_MAX)
|
||||
.map((h) => h.item);
|
||||
}
|
||||
|
||||
/** The mention a picked entry stands for; null for anything the popover did not build. */
|
||||
export function mentionOf(item: TriggerItem): AssistantMentionItem | null {
|
||||
const kind = ASSISTANT_MENTION_KINDS.find((k) => k === item.type);
|
||||
const id = item.metadata?.id;
|
||||
if (!kind || typeof id !== 'number') return null;
|
||||
return { kind, id, label: item.label };
|
||||
}
|
||||
|
||||
/** A pick goes into the text as `@label`; the text stays plain, what it names is kept in the store. */
|
||||
export const MENTION_FORMATTER: DirectiveFormatter = {
|
||||
serialize: (item) => `@${item.label}`,
|
||||
parse: (text) => [{ kind: 'text', text }],
|
||||
};
|
||||
|
||||
/** True when no letter or digit follows at `end`, so `@Rome` does not end inside `@Romeo`. */
|
||||
function endsWord(text: string, end: number): boolean {
|
||||
return !/[\p{L}\p{N}]/u.test(text.charAt(end));
|
||||
}
|
||||
|
||||
/** Where `@label` stands in the text as a whole word. */
|
||||
function tokenAt(text: string, label: string): number {
|
||||
const token = `@${label}`;
|
||||
for (let at = text.indexOf(token); at >= 0; at = text.indexOf(token, at + 1)) {
|
||||
if (endsWord(text, at + token.length)) return at;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function hasMention(text: string, label: string): boolean {
|
||||
return tokenAt(text, label) >= 0;
|
||||
}
|
||||
|
||||
/** The text with `@label ` written at its end, the way a pick from the popover leaves it. */
|
||||
export function withMention(text: string, label: string): string {
|
||||
if (hasMention(text, label)) return text;
|
||||
const gap = text === '' || /\s$/u.test(text) ? '' : ' ';
|
||||
return `${text}${gap}@${label} `;
|
||||
}
|
||||
|
||||
interface MentionSegment {
|
||||
text: string;
|
||||
/** Where the segment starts in the text. */
|
||||
at: number;
|
||||
/** True for an `@label` the question named a day, place or booking with. */
|
||||
mention: boolean;
|
||||
}
|
||||
|
||||
/** A sent question cut into plain text and its `@label` mentions; the longest label wins a tie. */
|
||||
export function splitMentions(text: string, labels: readonly string[]): MentionSegment[] {
|
||||
const tokens = [...new Set(labels)]
|
||||
.filter(Boolean)
|
||||
.map((l) => `@${l}`)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
const out: MentionSegment[] = [];
|
||||
let start = 0;
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const token =
|
||||
text[i] === '@' ? tokens.find((tk) => text.startsWith(tk, i) && endsWord(text, i + tk.length)) : undefined;
|
||||
if (token) {
|
||||
if (i > start) out.push({ text: text.slice(start, i), at: start, mention: false });
|
||||
out.push({ text: token, at: i, mention: true });
|
||||
i += token.length;
|
||||
start = i;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if (start < text.length) out.push({ text: text.slice(start), at: start, mention: false });
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* FE-AI-MODEL-001 to FE-AI-MODEL-010
|
||||
*
|
||||
* The pure half of the assistant's views: how a stored message reaches
|
||||
* @assistant-ui, what each error offers, how a reading tool is named while it
|
||||
* runs and once it says where it looks, and which questions the empty chat
|
||||
* suggests.
|
||||
*/
|
||||
import { ASSISTANT_ERROR_CODES, type AssistantMessage } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { errorView, liveStepText, suggestionsFor, toolStepKey, toThreadMessage, tripPhase } from './assistantModel';
|
||||
|
||||
const AT = '2026-10-21T08:00:00.000Z';
|
||||
|
||||
describe('toThreadMessage', () => {
|
||||
it('FE-AI-MODEL-001: passes text, tool calls with their approval and TREK data parts through, and drops empty text', () => {
|
||||
const message: AssistantMessage = {
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
status: 'awaiting_approval',
|
||||
createdAt: AT,
|
||||
parts: [
|
||||
{ type: 'text', text: '' },
|
||||
{ type: 'text', text: 'Adding it.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'create_todo',
|
||||
args: { name: 'Tickets' },
|
||||
approval: { id: 'c1' },
|
||||
},
|
||||
{ type: 'tool-call', toolCallId: 'r1', toolName: 'list_todos', args: {}, result: [], isError: false },
|
||||
{
|
||||
type: 'data-trek-applied',
|
||||
data: { toolCallId: 'c0', entity: 'todo', ids: [1], dayId: null, undoUntil: null },
|
||||
},
|
||||
],
|
||||
};
|
||||
const like = toThreadMessage(message);
|
||||
expect(like).toEqual({
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
createdAt: new Date(AT),
|
||||
content: [
|
||||
{ type: 'text', text: 'Adding it.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'create_todo',
|
||||
args: { name: 'Tickets' },
|
||||
approval: { id: 'c1' },
|
||||
},
|
||||
{ type: 'tool-call', toolCallId: 'r1', toolName: 'list_todos', args: {}, result: [], isError: false },
|
||||
{
|
||||
type: 'data-trek-applied',
|
||||
data: { toolCallId: 'c0', entity: 'todo', ids: [1], dayId: null, undoUntil: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
// Complete and waiting messages leave the status to the runtime.
|
||||
expect(like).not.toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('FE-AI-MODEL-002: a stopped or failed message says so', () => {
|
||||
const base: AssistantMessage = { id: 'm', role: 'assistant', parts: [], status: 'aborted', createdAt: AT };
|
||||
expect(toThreadMessage(base).status).toEqual({ type: 'incomplete', reason: 'cancelled' });
|
||||
expect(toThreadMessage({ ...base, status: 'error' }).status).toEqual({ type: 'incomplete', reason: 'error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorView', () => {
|
||||
it('FE-AI-MODEL-003: every error code has a line, and at most one action', () => {
|
||||
for (const code of ASSISTANT_ERROR_CODES) {
|
||||
const view = errorView(code, { canConnectOwn: true });
|
||||
expect(view.messageKey).toMatch(/^assistant\.error\./);
|
||||
if (view.action) expect(view.action.labelKey).toMatch(/^assistant\.errorAction\./);
|
||||
}
|
||||
expect(errorView('busy', { canConnectOwn: true }).action?.kind).toBe('continueHere');
|
||||
expect(errorView('provider_unavailable', { canConnectOwn: true })).toMatchObject({
|
||||
messageKey: 'assistant.error.providerDown',
|
||||
action: { kind: 'retry' },
|
||||
});
|
||||
expect(errorView('usage_limit', { canConnectOwn: true }).action?.kind).toBe('openUsage');
|
||||
expect(errorView('offline', { canConnectOwn: true }).action).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-MODEL-004: the daily limit offers an own provider only where the policy allows one', () => {
|
||||
expect(errorView('daily_limit', { canConnectOwn: true }).action).toEqual({
|
||||
kind: 'openSettings',
|
||||
labelKey: 'assistant.errorAction.connectOwn',
|
||||
});
|
||||
expect(errorView('daily_limit', { canConnectOwn: false }).action).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-MODEL-008: a provider limit names the provider when known, else keeps the general line', () => {
|
||||
expect(errorView('rate_limited', { canConnectOwn: true, provider: 'Mistral' }).messageKey).toBe(
|
||||
'assistant.error.providerLimit'
|
||||
);
|
||||
expect(errorView('usage_limit', { canConnectOwn: true, provider: 'ChatGPT' })).toMatchObject({
|
||||
messageKey: 'assistant.error.providerLimit',
|
||||
action: { kind: 'openUsage' },
|
||||
});
|
||||
expect(errorView('rate_limited', { canConnectOwn: true, provider: '' }).messageKey).toBe(
|
||||
'assistant.error.rateLimited'
|
||||
);
|
||||
expect(errorView('usage_limit', { canConnectOwn: true }).messageKey).toBe('assistant.error.usageLimit');
|
||||
expect(errorView('generic', { canConnectOwn: true, provider: 'Mistral' }).messageKey).toBe(
|
||||
'assistant.error.generic'
|
||||
);
|
||||
// TREK's own limit never blames the provider.
|
||||
expect(errorView('too_many_requests', { canConnectOwn: true, provider: 'OpenRouter' }).messageKey).toBe(
|
||||
'assistant.error.rateLimited'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-AI-MODEL-009: waiting changes ask for a decision first, with nothing to press', () => {
|
||||
expect(errorView('decide_first', { canConnectOwn: true })).toEqual({
|
||||
messageKey: 'assistant.error.decideFirst',
|
||||
action: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolStepKey', () => {
|
||||
it('FE-AI-MODEL-005: names reading tools by what they do, and anything else as working', () => {
|
||||
expect(toolStepKey('get_trip_summary')).toBe('assistant.step.reading');
|
||||
expect(toolStepKey('search_nearby_places')).toBe('assistant.step.nearby');
|
||||
expect(toolStepKey('get_settlement_summary')).toBe('assistant.step.costs');
|
||||
expect(toolStepKey('create_place')).toBe('assistant.step.working');
|
||||
expect(toolStepKey('constructor')).toBe('assistant.step.working');
|
||||
});
|
||||
|
||||
it('FE-AI-MODEL-010: a running step that names a place or a query says it, and falls back to its name', () => {
|
||||
expect(liveStepText('search_nearby_places', 'Trastevere')).toEqual({
|
||||
key: 'assistant.step.nearbyAt',
|
||||
params: { place: 'Trastevere' },
|
||||
});
|
||||
expect(liveStepText('search_pois', 'Shibuya')).toEqual({
|
||||
key: 'assistant.step.nearbyAt',
|
||||
params: { place: 'Shibuya' },
|
||||
});
|
||||
expect(liveStepText('search_place', 'gelato')).toEqual({
|
||||
key: 'assistant.step.searchingFor',
|
||||
params: { query: 'gelato' },
|
||||
});
|
||||
expect(liveStepText('get_weather', 'Kyoto')).toEqual({
|
||||
key: 'assistant.step.weatherAt',
|
||||
params: { place: 'Kyoto' },
|
||||
});
|
||||
expect(liveStepText('search_nearby_places', null)).toEqual({ key: 'assistant.step.nearby' });
|
||||
expect(liveStepText('list_places', 'Kyoto')).toEqual({ key: 'assistant.step.reading' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestions', () => {
|
||||
it('FE-AI-MODEL-006: the phase follows the dates and whether anything is planned', () => {
|
||||
const today = '2026-06-12';
|
||||
expect(tripPhase({ startDate: null, endDate: null, hasPlaces: false, today })).toBe('empty');
|
||||
expect(tripPhase({ startDate: '2026-07-01', endDate: '2026-07-05', hasPlaces: true, today })).toBe('upcoming');
|
||||
expect(tripPhase({ startDate: '2026-06-10', endDate: '2026-06-14', hasPlaces: false, today })).toBe('running');
|
||||
expect(tripPhase({ startDate: '2026-06-12', endDate: null, hasPlaces: true, today })).toBe('running');
|
||||
expect(tripPhase({ startDate: '2026-06-01', endDate: '2026-06-11', hasPlaces: true, today })).toBe('past');
|
||||
});
|
||||
|
||||
it('FE-AI-MODEL-007: focus comes first, at most four, and nothing that writes for someone who may only read', () => {
|
||||
const focused = suggestionsFor({ phase: 'upcoming', canEdit: true, place: 'Kinkaku-ji', day: 'Fri 12 Jun' });
|
||||
expect(focused.map((s) => s.id)).toEqual(['nearPlace', 'placeTips', 'planDay', 'dayTooFull']);
|
||||
expect(focused[0]?.params).toEqual({ place: 'Kinkaku-ji' });
|
||||
|
||||
const readOnly = suggestionsFor({ phase: 'empty', canEdit: false, day: 'Fri 12 Jun' });
|
||||
expect(readOnly.map((s) => s.id)).toEqual(['dayTooFull', 'mustSee', 'weather']);
|
||||
expect(readOnly.every((s) => !s.writes)).toBe(true);
|
||||
|
||||
expect(suggestionsFor({ phase: 'past', canEdit: true }).map((s) => s.labelKey)).toEqual([
|
||||
'assistant.suggest.settleUp',
|
||||
'assistant.suggest.recap',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { ThreadMessageLike } from '@assistant-ui/react';
|
||||
import type { AiStatusResponse, AssistantErrorCode, AssistantMessage, AssistantMessagePart } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Pure helpers the assistant's views share, desktop window and phone screen
|
||||
* alike: the bridge from stored messages to @assistant-ui, what an error says
|
||||
* and offers, how a reading tool is named while it runs, and which questions
|
||||
* the empty chat suggests. No React, and only a type from @assistant-ui, so
|
||||
* importing this keeps the chat runtime out of the chunk.
|
||||
*/
|
||||
|
||||
type LikePart = Exclude<ThreadMessageLike['content'], string>[number];
|
||||
|
||||
const STATUS: Partial<Record<AssistantMessage['status'], ThreadMessageLike['status']>> = {
|
||||
aborted: { type: 'incomplete', reason: 'cancelled' },
|
||||
error: { type: 'incomplete', reason: 'error' },
|
||||
};
|
||||
|
||||
function toPart(part: AssistantMessagePart): LikePart | null {
|
||||
if (part.type === 'text') return part.text ? { type: 'text', text: part.text } : null;
|
||||
// An attached file is drawn from the stored message; the runtime takes it as one more data part.
|
||||
if (part.type === 'attachment') {
|
||||
return { type: 'data-trek-attachment', data: { name: part.name, mime: part.mime, size: part.size } };
|
||||
}
|
||||
if (part.type !== 'tool-call') return part;
|
||||
return {
|
||||
type: 'tool-call',
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
args: part.args,
|
||||
...(part.result !== undefined ? { result: part.result } : {}),
|
||||
...(part.isError !== undefined ? { isError: part.isError } : {}),
|
||||
...(part.approval ? { approval: part.approval } : {}),
|
||||
} as LikePart;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored message as @assistant-ui reads it (the external store runtime's
|
||||
* `convertMessage`). Complete and waiting messages carry no status, so the
|
||||
* runtime derives it: running while the turn streams, waiting while a change
|
||||
* is undecided.
|
||||
*/
|
||||
export function toThreadMessage(message: AssistantMessage): ThreadMessageLike {
|
||||
const status = STATUS[message.status];
|
||||
return {
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
createdAt: new Date(message.createdAt),
|
||||
content: message.parts.map(toPart).filter((p): p is LikePart => p !== null),
|
||||
...(status ? { status } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Errors ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** What the one action under an error line does. */
|
||||
export type AssistantErrorActionKind = 'retry' | 'continueHere' | 'continue' | 'openSettings' | 'openUsage';
|
||||
|
||||
interface AssistantErrorView {
|
||||
messageKey: string;
|
||||
action: { kind: AssistantErrorActionKind; labelKey: string } | null;
|
||||
}
|
||||
|
||||
const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
|
||||
not_connected: { messageKey: 'assistant.error.notConnected', action: null },
|
||||
busy: {
|
||||
messageKey: 'assistant.error.busy',
|
||||
action: { kind: 'continueHere', labelKey: 'assistant.errorAction.continueHere' },
|
||||
},
|
||||
daily_limit: {
|
||||
messageKey: 'assistant.error.dailyLimit',
|
||||
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.connectOwn' },
|
||||
},
|
||||
rate_limited: { messageKey: 'assistant.error.rateLimited', action: null },
|
||||
needs_reauth: {
|
||||
messageKey: 'assistant.error.needsReauth',
|
||||
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.signIn' },
|
||||
},
|
||||
usage_limit: {
|
||||
messageKey: 'assistant.error.usageLimit',
|
||||
action: { kind: 'openUsage', labelKey: 'assistant.errorAction.viewUsage' },
|
||||
},
|
||||
not_eligible: {
|
||||
messageKey: 'assistant.error.notEligible',
|
||||
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.otherProvider' },
|
||||
},
|
||||
invalid_key: {
|
||||
messageKey: 'assistant.error.invalidKey',
|
||||
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.replaceKey' },
|
||||
},
|
||||
provider_unavailable: {
|
||||
messageKey: 'assistant.error.providerDown',
|
||||
action: { kind: 'retry', labelKey: 'assistant.errorAction.retry' },
|
||||
},
|
||||
max_steps: {
|
||||
messageKey: 'assistant.error.maxSteps',
|
||||
action: { kind: 'continue', labelKey: 'assistant.errorAction.continue' },
|
||||
},
|
||||
empty_answer: {
|
||||
messageKey: 'assistant.error.emptyAnswer',
|
||||
action: { kind: 'continue', labelKey: 'assistant.errorAction.continue' },
|
||||
},
|
||||
offline: { messageKey: 'assistant.error.offline', action: null },
|
||||
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
|
||||
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
|
||||
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
|
||||
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
|
||||
generic: {
|
||||
messageKey: 'assistant.error.generic',
|
||||
action: { kind: 'retry', labelKey: 'assistant.errorAction.retry' },
|
||||
},
|
||||
};
|
||||
|
||||
/** The limits a provider sets; the line names the provider instead of guessing at its plan. */
|
||||
const PROVIDER_LIMITS: ReadonlySet<AssistantErrorCode> = new Set(['rate_limited', 'usage_limit']);
|
||||
|
||||
/**
|
||||
* The line an error shows and its one action. The daily limit only offers
|
||||
* connecting an own provider where the policy allows one. A provider's limit
|
||||
* says whose it is when the provider is known (`provider` is its name, empty
|
||||
* when not), and keeps the general line otherwise.
|
||||
*/
|
||||
export function errorView(
|
||||
code: AssistantErrorCode,
|
||||
opts: { canConnectOwn: boolean; provider?: string }
|
||||
): AssistantErrorView {
|
||||
const view = ERRORS[code];
|
||||
if (code === 'daily_limit' && !opts.canConnectOwn) return { ...view, action: null };
|
||||
if (PROVIDER_LIMITS.has(code) && opts.provider) return { ...view, messageKey: 'assistant.error.providerLimit' };
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* The error the user's own connection shows before anything is sent, once it
|
||||
* stopped working: a sign-in to renew or a key to replace. The window says so
|
||||
* right away instead of waiting for the next question to fail.
|
||||
*/
|
||||
export function connectionErrorOf(status: AiStatusResponse | null): AssistantErrorCode | null {
|
||||
if (!status?.own || status.effective?.source !== 'user') return null;
|
||||
if (status.own.status === 'needs_reauth') return 'needs_reauth';
|
||||
if (status.own.status === 'error') return 'invalid_key';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Tool steps ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** `liveKey` names the step with the place or query it is at, `param` is the word that fills it. */
|
||||
interface StepLook {
|
||||
stepKey: string;
|
||||
live?: { liveKey: string; param: 'place' | 'query' };
|
||||
}
|
||||
|
||||
const STEPS: Partial<Record<string, StepLook>> = {
|
||||
get_trip_summary: { stepKey: 'assistant.step.reading' },
|
||||
list_places: { stepKey: 'assistant.step.reading' },
|
||||
search_place: {
|
||||
stepKey: 'assistant.step.searching',
|
||||
live: { liveKey: 'assistant.step.searchingFor', param: 'query' },
|
||||
},
|
||||
search_pois: { stepKey: 'assistant.step.searching', live: { liveKey: 'assistant.step.nearbyAt', param: 'place' } },
|
||||
get_place_details: { stepKey: 'assistant.step.details' },
|
||||
search_nearby_places: {
|
||||
stepKey: 'assistant.step.nearby',
|
||||
live: { liveKey: 'assistant.step.nearbyAt', param: 'place' },
|
||||
},
|
||||
list_categories: { stepKey: 'assistant.step.categories' },
|
||||
get_weather: { stepKey: 'assistant.step.weather', live: { liveKey: 'assistant.step.weatherAt', param: 'place' } },
|
||||
list_todos: { stepKey: 'assistant.step.todos' },
|
||||
get_settlement_summary: { stepKey: 'assistant.step.costs' },
|
||||
search_help: { stepKey: 'assistant.step.help' },
|
||||
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
|
||||
};
|
||||
const WORKING = { stepKey: 'assistant.step.working' };
|
||||
|
||||
/** How a reading tool is named while it runs: "Reading your plan", never the tool's name. */
|
||||
export function toolStepKey(toolName: string): string {
|
||||
return STEPS[toolName]?.stepKey ?? WORKING.stepKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a running step is named once it said where it looks: "Searching near
|
||||
* Trastevere", "Looking for gelato". The general name when it named nothing
|
||||
* or the step has no such line.
|
||||
*/
|
||||
export function liveStepText(toolName: string, label: string | null): { key: string; params?: Record<string, string> } {
|
||||
const live = STEPS[toolName]?.live;
|
||||
if (!live || !label) return { key: toolStepKey(toolName) };
|
||||
return { key: live.liveKey, params: { [live.param]: label } };
|
||||
}
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A temperature the server sends in °C, in the user's unit, rounded; never "-0". */
|
||||
export function inUnit(celsius: number, fahrenheit: boolean): number {
|
||||
return Math.round(fahrenheit ? (celsius * 9) / 5 + 32 : celsius) || 0;
|
||||
}
|
||||
|
||||
// ── Suggestions ─────────────────────────────────────────────────────────────
|
||||
|
||||
type TripPhase = 'empty' | 'upcoming' | 'running' | 'past';
|
||||
|
||||
/** Where the trip stands on `today` (YYYY-MM-DD, in the user's zone). Dates missing read as not started. */
|
||||
export function tripPhase(trip: {
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
hasPlaces: boolean;
|
||||
today: string;
|
||||
}): TripPhase {
|
||||
const { startDate, endDate, today } = trip;
|
||||
if (endDate && endDate < today) return 'past';
|
||||
if (startDate && startDate <= today && (!endDate || today <= endDate)) return 'running';
|
||||
return trip.hasPlaces ? 'upcoming' : 'empty';
|
||||
}
|
||||
|
||||
interface AssistantSuggestion {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
params?: Record<string, string>;
|
||||
/** Asks the assistant to change the trip, so it is left out for someone who may only read. */
|
||||
writes?: boolean;
|
||||
}
|
||||
|
||||
const PHASE_SUGGESTIONS: Record<TripPhase, AssistantSuggestion[]> = {
|
||||
empty: [
|
||||
{ id: 'planFirstDays', labelKey: 'assistant.suggest.planFirstDays', writes: true },
|
||||
{ id: 'mustSee', labelKey: 'assistant.suggest.mustSee' },
|
||||
{ id: 'packingList', labelKey: 'assistant.suggest.packingList', writes: true },
|
||||
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
|
||||
],
|
||||
upcoming: [
|
||||
{ id: 'emptyDays', labelKey: 'assistant.suggest.emptyDays' },
|
||||
{ id: 'packingList', labelKey: 'assistant.suggest.packingList', writes: true },
|
||||
{ id: 'rainPlan', labelKey: 'assistant.suggest.rainPlan' },
|
||||
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
|
||||
],
|
||||
running: [
|
||||
{ id: 'today', labelKey: 'assistant.suggest.today' },
|
||||
{ id: 'eatNearby', labelKey: 'assistant.suggest.eatNearby' },
|
||||
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
|
||||
{ id: 'openTodos', labelKey: 'assistant.suggest.openTodos' },
|
||||
],
|
||||
past: [
|
||||
{ id: 'settleUp', labelKey: 'assistant.suggest.settleUp' },
|
||||
{ id: 'recap', labelKey: 'assistant.suggest.recap' },
|
||||
],
|
||||
};
|
||||
|
||||
const MAX_SUGGESTIONS = 4;
|
||||
|
||||
/**
|
||||
* The questions the empty chat offers: about the place or day in focus first,
|
||||
* then what fits the trip's phase. The label is also the prompt, so a tapped
|
||||
* suggestion reads in the chat exactly as it was offered.
|
||||
*/
|
||||
export function suggestionsFor(input: {
|
||||
phase: TripPhase;
|
||||
canEdit: boolean;
|
||||
/** The name of the place in focus. */
|
||||
place?: string | null;
|
||||
/** The label of the day in focus ("Fri 12 Jun"). */
|
||||
day?: string | null;
|
||||
}): AssistantSuggestion[] {
|
||||
const focused: AssistantSuggestion[] = [];
|
||||
if (input.place) {
|
||||
focused.push(
|
||||
{ id: 'nearPlace', labelKey: 'assistant.suggest.nearPlace', params: { place: input.place } },
|
||||
{ id: 'placeTips', labelKey: 'assistant.suggest.placeTips', params: { place: input.place } }
|
||||
);
|
||||
}
|
||||
if (input.day) {
|
||||
focused.push(
|
||||
{ id: 'planDay', labelKey: 'assistant.suggest.planDay', params: { day: input.day }, writes: true },
|
||||
{ id: 'dayTooFull', labelKey: 'assistant.suggest.dayTooFull', params: { day: input.day } }
|
||||
);
|
||||
}
|
||||
const picked: AssistantSuggestion[] = [];
|
||||
for (const suggestion of [...focused, ...PHASE_SUGGESTIONS[input.phase]]) {
|
||||
if (suggestion.writes && !input.canEdit) continue;
|
||||
if (picked.some((p) => p.id === suggestion.id)) continue;
|
||||
picked.push(suggestion);
|
||||
}
|
||||
return picked.slice(0, MAX_SUGGESTIONS);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* FE-AI-PARTS-001 to FE-AI-PARTS-014
|
||||
*
|
||||
* How an answer is laid out: text, the steps the assistant looked up, the
|
||||
* places and the weather they found, and one card per batch of proposed
|
||||
* changes, each change in the state its approval and receipt put it in.
|
||||
*/
|
||||
import {
|
||||
ASSISTANT_CHANGE_LABEL_KEYS,
|
||||
type AssistantApplied,
|
||||
type AssistantChangePreview,
|
||||
type AssistantFoundPlace,
|
||||
type AssistantMessage,
|
||||
type AssistantMessagePart,
|
||||
type AssistantWeatherDay,
|
||||
} from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CHANGE_LABELS, hasUndecided, messageBlocks, suggestedFromStrip, undoLeftMs } from './assistantParts';
|
||||
|
||||
const AT = '2026-10-21T08:00:00.000Z';
|
||||
|
||||
function message(parts: AssistantMessagePart[]): AssistantMessage {
|
||||
return { id: 'm1', role: 'assistant', status: 'complete', createdAt: AT, parts };
|
||||
}
|
||||
|
||||
function preview(toolCallId: string): AssistantChangePreview {
|
||||
return {
|
||||
toolCallId,
|
||||
verbKey: 'addTodo',
|
||||
subject: `Todo ${toolCallId}`,
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function applied(toolCallId: string, undoUntil: string | null = null): AssistantApplied {
|
||||
return { toolCallId, entity: 'todo', ids: [1], dayId: null, undoUntil };
|
||||
}
|
||||
|
||||
const read = (id: string, result?: unknown): AssistantMessagePart => ({
|
||||
type: 'tool-call',
|
||||
toolCallId: id,
|
||||
toolName: 'list_places',
|
||||
args: {},
|
||||
...(result !== undefined ? { result } : {}),
|
||||
});
|
||||
|
||||
const write = (
|
||||
id: string,
|
||||
approval: { approved?: boolean; resolution?: 'cancelled' | 'expired' } = {},
|
||||
outcome: { result?: unknown; isError?: boolean } = {}
|
||||
): AssistantMessagePart => ({
|
||||
type: 'tool-call',
|
||||
toolCallId: id,
|
||||
toolName: 'create_todo',
|
||||
args: {},
|
||||
approval: { id, ...approval },
|
||||
...outcome,
|
||||
});
|
||||
|
||||
describe('messageBlocks', () => {
|
||||
it('FE-AI-PARTS-001: keeps text, gathers adjacent reading steps and drops empty text', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
{ type: 'text', text: 'Let me look.' },
|
||||
read('r1', { ok: 1 }),
|
||||
read('r2', { ok: 2 }),
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', text: 'Found two.' },
|
||||
]),
|
||||
false
|
||||
);
|
||||
expect(blocks.map((b) => b.kind)).toEqual(['text', 'steps', 'text']);
|
||||
expect(blocks[1]).toMatchObject({
|
||||
kind: 'steps',
|
||||
steps: [
|
||||
{ toolCallId: 'r1', running: false },
|
||||
{ toolCallId: 'r2', running: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-002: a step without its result runs only while the answer is being written', () => {
|
||||
const parts = [read('r1')];
|
||||
expect(messageBlocks(message(parts), true)[0]).toMatchObject({ steps: [{ running: true }] });
|
||||
expect(messageBlocks(message(parts), false)[0]).toMatchObject({ steps: [{ running: false }] });
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-003: the changes of one step share a card, each with its preview, wherever the preview sits', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
write('w1'),
|
||||
{ type: 'data-trek-change', data: preview('w1') },
|
||||
write('w2'),
|
||||
{ type: 'data-trek-change', data: preview('w2') },
|
||||
]),
|
||||
false
|
||||
);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
kind: 'change',
|
||||
items: [
|
||||
{ toolCallId: 'w1', state: 'pending', preview: { subject: 'Todo w1' } },
|
||||
{ toolCallId: 'w2', state: 'pending', preview: { subject: 'Todo w2' } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-004: reads each change state off its approval, result and receipt', () => {
|
||||
const parts: AssistantMessagePart[] = [
|
||||
write('applying', { approved: true }),
|
||||
write('done', { approved: true }, { result: { id: 1 } }),
|
||||
write('receipt', { approved: true }),
|
||||
{ type: 'data-trek-applied', data: applied('receipt') },
|
||||
write('broke', { approved: true }, { result: 'nope', isError: true }),
|
||||
write('no', { approved: false }),
|
||||
write('late', { resolution: 'expired' }),
|
||||
];
|
||||
const [card] = messageBlocks(message(parts), false);
|
||||
expect(card?.kind === 'change' && card.items.map((i) => [i.toolCallId, i.state])).toEqual([
|
||||
['applying', 'applying'],
|
||||
['done', 'applied'],
|
||||
['receipt', 'applied'],
|
||||
['broke', 'failed'],
|
||||
['no', 'discarded'],
|
||||
['late', 'expired'],
|
||||
]);
|
||||
expect(card?.kind === 'change' && card.items[2]?.applied).toEqual(applied('receipt'));
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-005: text between two batches of changes splits them into two cards', () => {
|
||||
const blocks = messageBlocks(message([write('w1'), { type: 'text', text: 'And one more.' }, write('w2')]), false);
|
||||
expect(blocks.map((b) => b.kind)).toEqual(['change', 'text', 'change']);
|
||||
});
|
||||
});
|
||||
|
||||
function found(ref: string, extra: Partial<AssistantFoundPlace> = {}): AssistantFoundPlace {
|
||||
return {
|
||||
ref,
|
||||
name: `Place ${ref}`,
|
||||
address: null,
|
||||
lat: 41.89,
|
||||
lng: 12.47,
|
||||
kind: 'food',
|
||||
rating: null,
|
||||
openNow: null,
|
||||
googlePlaceId: null,
|
||||
osmId: null,
|
||||
photoKey: null,
|
||||
inTripPlaceId: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const places = (toolCallId: string, list: AssistantFoundPlace[], more = 0): AssistantMessagePart => ({
|
||||
type: 'data-trek-places',
|
||||
data: { toolCallId, places: list, more },
|
||||
});
|
||||
|
||||
function sky(date: string, tempMax: number): AssistantWeatherDay {
|
||||
return { dayId: null, date, main: 'Clear', tempMax, tempMin: null, rainPct: null, kind: 'forecast' };
|
||||
}
|
||||
|
||||
/** A change of the model that adds a place to day 3: a new one at a spot, or the trip's own by id. */
|
||||
function adds(id: string, spot: { lat: number; lng: number } | null, placeId: number | null): AssistantMessagePart {
|
||||
return {
|
||||
type: 'data-trek-change',
|
||||
data: {
|
||||
...preview(id),
|
||||
effect: {
|
||||
kind: 'add',
|
||||
dayId: 3,
|
||||
fromDayId: null,
|
||||
placeId,
|
||||
assignmentId: null,
|
||||
place: spot && { name: 'New', categoryId: null, ...spot },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('messageBlocks: found places and weather', () => {
|
||||
it('FE-AI-PARTS-008: joins the places of all searches into one strip, each once, and steps after it join the row before it', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
read('s1', []),
|
||||
places('s1', [found('s1:0'), found('s1:1', { googlePlaceId: 'ChIJa' })], 2),
|
||||
read('s2', []),
|
||||
places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3),
|
||||
places('s3', [found('s3:0', { lat: 40 })]),
|
||||
{ type: 'text', text: 'Three good ones.' },
|
||||
]),
|
||||
false
|
||||
);
|
||||
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'places', 'text']);
|
||||
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
|
||||
// The same name at the same spot, or the same Google id, shows once.
|
||||
expect(blocks[1]).toMatchObject({
|
||||
found: { toolCallId: 's3', more: 5, places: [{ ref: 's1:0' }, { ref: 's1:1' }, { ref: 's3:0' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-009: drops the places a change of the model proposes, and the strip if none is left', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
places('s1', [found('s1:0'), found('s1:1', { lat: 40, inTripPlaceId: 8 }), found('s1:2', { lat: 39 })]),
|
||||
write('w1'),
|
||||
adds('w1', { lat: 41.89, lng: 12.47 }, null),
|
||||
write('w2'),
|
||||
adds('w2', null, 8),
|
||||
]),
|
||||
false
|
||||
);
|
||||
expect(blocks[0]).toMatchObject({ kind: 'places', found: { places: [{ ref: 's1:2' }] } });
|
||||
|
||||
const none = messageBlocks(
|
||||
message([places('s1', [found('s1:0')]), write('w1'), adds('w1', { lat: 41.89, lng: 12.47 }, null)]),
|
||||
false
|
||||
);
|
||||
expect(none.map((b) => b.kind)).toEqual(['change']);
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-010: a place suggested from the strip stays on it and reads as suggested', () => {
|
||||
const msg = message([
|
||||
places('s1', [found('s1:0'), found('s1:1', { lat: 40 })]),
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'f1',
|
||||
toolName: 'add_found_place',
|
||||
args: { via: 'create_and_assign_place', dayId: 3, name: 'Place s1:0', lat: 41.89, lng: 12.47 },
|
||||
approval: { id: 'f1' },
|
||||
},
|
||||
adds('f1', { lat: 41.89, lng: 12.47 }, null),
|
||||
]);
|
||||
const [strip] = messageBlocks(msg, false);
|
||||
expect(strip).toMatchObject({ kind: 'places', found: { places: [{ ref: 's1:0' }, { ref: 's1:1' }] } });
|
||||
expect(suggestedFromStrip(msg, found('s1:0'))).toBe(true);
|
||||
expect(suggestedFromStrip(msg, found('s1:1', { lat: 40 }))).toBe(false);
|
||||
|
||||
const inTrip = message([
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'f2',
|
||||
toolName: 'add_found_place',
|
||||
args: { placeId: 8 },
|
||||
approval: { id: 'f2' },
|
||||
},
|
||||
]);
|
||||
expect(suggestedFromStrip(inTrip, found('x', { inTripPlaceId: 8 }))).toBe(true);
|
||||
expect(suggestedFromStrip(inTrip, found('x', { inTripPlaceId: 9 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-014: each suggestion from the strip is a card of its own, apart from the changes around it', () => {
|
||||
const fromStrip = (id: string, approval: { approved?: boolean } = {}): AssistantMessagePart => ({
|
||||
type: 'tool-call',
|
||||
toolCallId: id,
|
||||
toolName: 'add_found_place',
|
||||
args: { placeId: 8 },
|
||||
approval: { id, ...approval },
|
||||
});
|
||||
const blocks = messageBlocks(
|
||||
message([write('w1', { approved: true }), fromStrip('f1', { approved: true }), fromStrip('f2'), write('w2')]),
|
||||
false
|
||||
);
|
||||
expect(blocks.map((b) => (b.kind === 'change' ? b.items.map((i) => i.toolCallId) : b.kind))).toEqual([
|
||||
['w1'],
|
||||
['f1'],
|
||||
['f2'],
|
||||
['w2'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-011: the weather of all steps is one row, one entry per date, where the first look sat', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
read('w1', {}),
|
||||
{ type: 'data-trek-weather', data: { days: [sky('2026-11-03', 18), sky('2026-11-02', 20)] } },
|
||||
{ type: 'text', text: 'Mild.' },
|
||||
{ type: 'data-trek-weather', data: { days: [sky('2026-11-03', 15)] } },
|
||||
]),
|
||||
false
|
||||
);
|
||||
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'weather', 'text']);
|
||||
expect(blocks[1]).toMatchObject({
|
||||
kind: 'weather',
|
||||
days: [
|
||||
{ date: '2026-11-02', tempMax: 20 },
|
||||
{ date: '2026-11-03', tempMax: 15 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasUndecided', () => {
|
||||
it('FE-AI-PARTS-012: is true only while a change waits for the user', () => {
|
||||
expect(hasUndecided([message([write('w1')])])).toBe(true);
|
||||
expect(hasUndecided([message([write('w1', { approved: true })])])).toBe(false);
|
||||
expect(hasUndecided([message([write('w1', { resolution: 'expired' })])])).toBe(false);
|
||||
expect(hasUndecided([message([read('r1')])])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undoLeftMs', () => {
|
||||
it('FE-AI-PARTS-006: counts down to the end of the undo window and stops at zero', () => {
|
||||
const now = Date.parse(AT);
|
||||
expect(undoLeftMs(applied('a', '2026-10-21T08:10:00.000Z'), now)).toBe(10 * 60_000);
|
||||
expect(undoLeftMs(applied('a', '2026-10-21T07:59:00.000Z'), now)).toBe(0);
|
||||
expect(undoLeftMs(applied('a', null), now)).toBe(0);
|
||||
expect(undoLeftMs(null, now)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHANGE_LABELS', () => {
|
||||
it('FE-AI-PARTS-013: names a route', () => {
|
||||
expect(CHANGE_LABELS.route.labelKey).toBe('assistant.change.label.route');
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-007: names every field a change row can carry', () => {
|
||||
for (const key of ASSISTANT_CHANGE_LABEL_KEYS) {
|
||||
expect(CHANGE_LABELS[key].labelKey).toBe(`assistant.change.label.${key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import type {
|
||||
AssistantApplied,
|
||||
AssistantChangeEffect,
|
||||
AssistantChangeLabelKey,
|
||||
AssistantChangePreview,
|
||||
AssistantFoundPlace,
|
||||
AssistantMessage,
|
||||
AssistantMessagePart,
|
||||
AssistantPlaces,
|
||||
AssistantWeatherDay,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* How an answer is laid out: its stored parts read as blocks. Text stays
|
||||
* text; the reading tools of a run sit together as one row of steps; the
|
||||
* changes the model proposes in one go sit together as one card, each with
|
||||
* its preview and, once applied, its receipt. The places a search found sit
|
||||
* as one strip, the newest search's, and the weather the steps looked up as
|
||||
* one row of days. Pure, so the window and the phone screen lay an answer out
|
||||
* the same way and a test can pin it.
|
||||
*/
|
||||
|
||||
/** Where the settings card for the user's own AI connection lives. */
|
||||
export const AI_SETTINGS_PATH = '/settings?tab=integrations';
|
||||
|
||||
type ToolCallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
|
||||
|
||||
/** One reading tool the assistant ran or runs. */
|
||||
export interface ToolStep {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
/** Still waiting for its result while the answer is being written. */
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a proposed change stands: waiting for the user, being applied,
|
||||
* applied, failed, discarded, or closed without a decision (expired, or the
|
||||
* turn was stopped).
|
||||
*/
|
||||
export type ChangeState = 'pending' | 'applying' | 'applied' | 'failed' | 'discarded' | 'expired';
|
||||
|
||||
export interface ChangeItem {
|
||||
toolCallId: string;
|
||||
/** What the card says about it; null until the preview arrived. */
|
||||
preview: AssistantChangePreview | null;
|
||||
/** The receipt of an applied change. */
|
||||
applied: AssistantApplied | null;
|
||||
state: ChangeState;
|
||||
}
|
||||
|
||||
type MessageBlock =
|
||||
| { kind: 'text'; key: string; text: string }
|
||||
| { kind: 'steps'; key: string; steps: ToolStep[] }
|
||||
| { kind: 'change'; key: string; items: ChangeItem[] }
|
||||
| { kind: 'places'; key: string; found: AssistantPlaces }
|
||||
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] };
|
||||
|
||||
/** The change "+ Day 3" on a found place makes; the server holds it without a model turn. */
|
||||
const FOUND_PLACE_TOOL = 'add_found_place';
|
||||
|
||||
/** Two points this close (about ten metres) are the same place. */
|
||||
const SAME_SPOT_DEG = 1e-4;
|
||||
|
||||
/** A found place one of the answer's own changes already proposes, so its card would say the same twice. */
|
||||
function proposed(place: AssistantFoundPlace, effects: readonly AssistantChangeEffect[]): boolean {
|
||||
return effects.some((effect) => {
|
||||
if (effect.placeId !== null && effect.placeId === place.inTripPlaceId) return true;
|
||||
const spot = effect.place;
|
||||
return (
|
||||
spot !== null && Math.abs(spot.lat - place.lat) < SAME_SPOT_DEG && Math.abs(spot.lng - place.lng) < SAME_SPOT_DEG
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Every day the answer's weather steps looked up, one entry per date (the later look wins), by date. */
|
||||
function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDay[] {
|
||||
const byDate = new Map<string, AssistantWeatherDay>();
|
||||
for (const part of parts) {
|
||||
if (part.type === 'data-trek-weather') for (const day of part.data.days) byDate.set(day.date, day);
|
||||
}
|
||||
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
/** The handles one place goes by: its ids, and its name at its spot (to about 100 m). */
|
||||
function placeKeys(place: AssistantFoundPlace): string[] {
|
||||
const ids = [place.googlePlaceId && `g:${place.googlePlaceId}`, place.osmId && `o:${place.osmId}`];
|
||||
const spot = `n:${place.name.trim().toLowerCase()}@${place.lat.toFixed(3)},${place.lng.toFixed(3)}`;
|
||||
return [...ids.filter((key): key is string => !!key), spot];
|
||||
}
|
||||
|
||||
/**
|
||||
* The places all searches of an answer found, in the order they came, each
|
||||
* once (also when two searches name it by different ids, as long as name and
|
||||
* spot agree), with what the searches left out counted together.
|
||||
*/
|
||||
function allFound(parts: AssistantMessage['parts']): AssistantPlaces {
|
||||
const seen = new Set<string>();
|
||||
const places: AssistantFoundPlace[] = [];
|
||||
let more = 0;
|
||||
let toolCallId = '';
|
||||
for (const part of parts) {
|
||||
if (part.type !== 'data-trek-places') continue;
|
||||
toolCallId = part.data.toolCallId;
|
||||
more += part.data.more;
|
||||
for (const place of part.data.places) {
|
||||
const keys = placeKeys(place);
|
||||
if (keys.some((key) => seen.has(key))) continue;
|
||||
keys.forEach((key) => seen.add(key));
|
||||
places.push(place);
|
||||
}
|
||||
}
|
||||
return { toolCallId, places, more };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a change to the change block before it, or starts one. A suggestion
|
||||
* from the strip is a block of its own, decided apart from the changes before
|
||||
* and after it, even where the answer joins them.
|
||||
*/
|
||||
function pushChange(blocks: MessageBlock[], item: ChangeItem, fromStrip: ReadonlySet<string>): void {
|
||||
const last = blocks[blocks.length - 1];
|
||||
const lastId = last?.kind === 'change' ? last.items[last.items.length - 1]?.toolCallId : undefined;
|
||||
const apart = fromStrip.has(item.toolCallId) || (lastId !== undefined && fromStrip.has(lastId));
|
||||
if (last?.kind === 'change' && !apart) last.items.push(item);
|
||||
else blocks.push({ kind: 'change', key: `change-${item.toolCallId}`, items: [item] });
|
||||
}
|
||||
|
||||
/** The steps block a new step joins: the last one, past any strip shown after it. */
|
||||
function openSteps(blocks: readonly MessageBlock[]): Extract<MessageBlock, { kind: 'steps' }> | null {
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
const block = blocks[i];
|
||||
if (block?.kind === 'steps') return block;
|
||||
if (block?.kind !== 'places' && block?.kind !== 'weather') return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function changeState(part: ToolCallPart, applied: AssistantApplied | null): ChangeState {
|
||||
const approval = part.approval;
|
||||
if (approval?.resolution) return 'expired';
|
||||
if (approval?.approved === false) return 'discarded';
|
||||
if (approval?.approved === true) {
|
||||
if (part.isError) return 'failed';
|
||||
return applied || part.result !== undefined ? 'applied' : 'applying';
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocks of a message, in the order its parts came. `writing` says the
|
||||
* message is still being streamed, so a step without a result is running and
|
||||
* not merely unanswered.
|
||||
*
|
||||
* The places of all its searches join into one strip where the newest search
|
||||
* sat, each place once, without the ones a change the model proposed in the
|
||||
* answer already holds; the weather of all steps joins
|
||||
* into one row where the first look sat. A strip does not split the steps
|
||||
* around it: a step after it joins the row of steps before it.
|
||||
*/
|
||||
export function messageBlocks(message: AssistantMessage, writing: boolean): MessageBlock[] {
|
||||
const previews = new Map<string, AssistantChangePreview>();
|
||||
const receipts = new Map<string, AssistantApplied>();
|
||||
const effects: AssistantChangeEffect[] = [];
|
||||
// A place the user suggested from the strip stays on it, marked as suggested.
|
||||
const fromStrip = new Set(
|
||||
message.parts.flatMap((p) => (p.type === 'tool-call' && p.toolName === FOUND_PLACE_TOOL ? [p.toolCallId] : []))
|
||||
);
|
||||
let lastPlaces = -1;
|
||||
let firstWeather = -1;
|
||||
message.parts.forEach((part, index) => {
|
||||
if (part.type === 'data-trek-change') {
|
||||
previews.set(part.data.toolCallId, part.data);
|
||||
if (part.data.effect && !fromStrip.has(part.data.toolCallId)) effects.push(part.data.effect);
|
||||
} else if (part.type === 'data-trek-applied') receipts.set(part.data.toolCallId, part.data);
|
||||
else if (part.type === 'data-trek-places') lastPlaces = index;
|
||||
else if (part.type === 'data-trek-weather' && firstWeather < 0) firstWeather = index;
|
||||
});
|
||||
|
||||
const blocks: MessageBlock[] = [];
|
||||
message.parts.forEach((part, index) => {
|
||||
if (part.type === 'text') {
|
||||
if (part.text.trim()) blocks.push({ kind: 'text', key: `text-${index}`, text: part.text });
|
||||
return;
|
||||
}
|
||||
if (part.type === 'data-trek-places' && index === lastPlaces) {
|
||||
const found = allFound(message.parts);
|
||||
const places = found.places.filter((place) => !proposed(place, effects));
|
||||
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } });
|
||||
return;
|
||||
}
|
||||
if (part.type === 'data-trek-weather' && index === firstWeather) {
|
||||
blocks.push({ kind: 'weather', key: `weather-${index}`, days: weatherDays(message.parts) });
|
||||
return;
|
||||
}
|
||||
if (part.type !== 'tool-call') return;
|
||||
const preview = previews.get(part.toolCallId) ?? null;
|
||||
if (part.approval || preview) {
|
||||
const applied = receipts.get(part.toolCallId) ?? null;
|
||||
const item: ChangeItem = { toolCallId: part.toolCallId, preview, applied, state: changeState(part, applied) };
|
||||
pushChange(blocks, item, fromStrip);
|
||||
return;
|
||||
}
|
||||
const step: ToolStep = {
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
running: writing && part.result === undefined,
|
||||
};
|
||||
const steps = openSteps(blocks);
|
||||
if (steps) steps.steps.push(step);
|
||||
else blocks.push({ kind: 'steps', key: `steps-${part.toolCallId}`, steps: [step] });
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether "+ Day" was already pressed on this found place in the answer: a
|
||||
* suggestion from the strip names the trip's place by its id, a new one by
|
||||
* the spot the search found it at.
|
||||
*/
|
||||
export function suggestedFromStrip(message: AssistantMessage, place: AssistantFoundPlace): boolean {
|
||||
return message.parts.some((part) => {
|
||||
if (part.type !== 'tool-call' || part.toolName !== FOUND_PLACE_TOOL) return false;
|
||||
const args = part.args as { placeId?: unknown; lat?: unknown; lng?: unknown };
|
||||
if (place.inTripPlaceId !== null && args.placeId === place.inTripPlaceId) return true;
|
||||
return (
|
||||
typeof args.lat === 'number' &&
|
||||
typeof args.lng === 'number' &&
|
||||
Math.abs(args.lat - place.lat) < SAME_SPOT_DEG &&
|
||||
Math.abs(args.lng - place.lng) < SAME_SPOT_DEG
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether a change somewhere in the chat still waits for the user; nothing new can be suggested until then. */
|
||||
export function hasUndecided(messages: readonly AssistantMessage[]): boolean {
|
||||
return messages.some((m) =>
|
||||
m.parts.some(
|
||||
(p) => p.type === 'tool-call' && !!p.approval && p.approval.approved === undefined && !p.approval.resolution
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** How long an applied change can still be undone, in ms; 0 once it cannot. */
|
||||
export function undoLeftMs(applied: AssistantApplied | null, now: number): number {
|
||||
if (!applied?.undoUntil) return 0;
|
||||
return Math.max(0, Date.parse(applied.undoUntil) - now);
|
||||
}
|
||||
|
||||
/** The label of each field a change row can show. A table, so every key is named where it is read. */
|
||||
export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }> = {
|
||||
// A tool's own argument: the row names it in `field`, this is only its fallback.
|
||||
field: { labelKey: 'assistant.change.label.field' },
|
||||
order: { labelKey: 'assistant.change.label.order' },
|
||||
name: { labelKey: 'assistant.change.label.name' },
|
||||
description: { labelKey: 'assistant.change.label.description' },
|
||||
address: { labelKey: 'assistant.change.label.address' },
|
||||
coordinates: { labelKey: 'assistant.change.label.coordinates' },
|
||||
category: { labelKey: 'assistant.change.label.category' },
|
||||
day: { labelKey: 'assistant.change.label.day' },
|
||||
time: { labelKey: 'assistant.change.label.time' },
|
||||
endTime: { labelKey: 'assistant.change.label.endTime' },
|
||||
notes: { labelKey: 'assistant.change.label.notes' },
|
||||
website: { labelKey: 'assistant.change.label.website' },
|
||||
phone: { labelKey: 'assistant.change.label.phone' },
|
||||
email: { labelKey: 'assistant.change.label.email' },
|
||||
openingHours: { labelKey: 'assistant.change.label.openingHours' },
|
||||
price: { labelKey: 'assistant.change.label.price' },
|
||||
currency: { labelKey: 'assistant.change.label.currency' },
|
||||
stopType: { labelKey: 'assistant.change.label.stopType' },
|
||||
text: { labelKey: 'assistant.change.label.text' },
|
||||
icon: { labelKey: 'assistant.change.label.icon' },
|
||||
color: { labelKey: 'assistant.change.label.color' },
|
||||
position: { labelKey: 'assistant.change.label.position' },
|
||||
quantity: { labelKey: 'assistant.change.label.quantity' },
|
||||
weight: { labelKey: 'assistant.change.label.weight' },
|
||||
bag: { labelKey: 'assistant.change.label.bag' },
|
||||
packed: { labelKey: 'assistant.change.label.packed' },
|
||||
dueDate: { labelKey: 'assistant.change.label.dueDate' },
|
||||
priority: { labelKey: 'assistant.change.label.priority' },
|
||||
assignee: { labelKey: 'assistant.change.label.assignee' },
|
||||
type: { labelKey: 'assistant.change.label.type' },
|
||||
location: { labelKey: 'assistant.change.label.location' },
|
||||
link: { labelKey: 'assistant.change.label.link' },
|
||||
confirmation: { labelKey: 'assistant.change.label.confirmation' },
|
||||
checkIn: { labelKey: 'assistant.change.label.checkIn' },
|
||||
checkOut: { labelKey: 'assistant.change.label.checkOut' },
|
||||
stay: { labelKey: 'assistant.change.label.stay' },
|
||||
members: { labelKey: 'assistant.change.label.members' },
|
||||
route: { labelKey: 'assistant.change.label.route' },
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* FE-AI-SHARE-001 to FE-AI-SHARE-005
|
||||
*
|
||||
* What an answer says once it goes to the trip chat: Markdown read as plain
|
||||
* text, the applied changes on request and only those, and a cut at a word
|
||||
* end that says so.
|
||||
*/
|
||||
import type { AssistantChangePreview, AssistantMessage, AssistantMessagePart } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { chatTextOf, markdownToText, SHARE_MAX_CHARS } from './assistantShare';
|
||||
|
||||
const t = (key: string) => (key === 'assistant.share.changesHeading' ? 'Applied:' : `<${key}>`);
|
||||
|
||||
function message(parts: AssistantMessagePart[]): AssistantMessage {
|
||||
return { id: 'msg-3', role: 'assistant', status: 'complete', createdAt: '2026-10-21T08:00:00.000Z', parts };
|
||||
}
|
||||
|
||||
function change(id: string, subject: string, approval: { approved?: boolean }): AssistantMessagePart[] {
|
||||
const preview: AssistantChangePreview = {
|
||||
toolCallId: id,
|
||||
verbKey: 'addPlace',
|
||||
subject,
|
||||
rows: [],
|
||||
dayId: 3,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
};
|
||||
return [
|
||||
{ type: 'tool-call', toolCallId: id, toolName: 'create_place', args: {}, approval: { id, ...approval } },
|
||||
{ type: 'data-trek-change', data: preview },
|
||||
];
|
||||
}
|
||||
|
||||
describe('markdownToText', () => {
|
||||
it('FE-AI-SHARE-001: reads headings, emphasis, code, lists, links, quotes and tables as plain text', () => {
|
||||
const text = markdownToText(
|
||||
[
|
||||
'## Day 3',
|
||||
'',
|
||||
'Go **early** to *Fushimi Inari*, take `JR Nara`.',
|
||||
'- [Tickets](https://example.com/t)',
|
||||
'* https://example.com',
|
||||
'> Bring water',
|
||||
'',
|
||||
'| Stop | Time |',
|
||||
'|---|:--:|',
|
||||
'| Shrine | 08:00 |',
|
||||
'```',
|
||||
'**kept**',
|
||||
'```',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'Done.',
|
||||
].join('\n')
|
||||
);
|
||||
expect(text).toBe(
|
||||
[
|
||||
'Day 3',
|
||||
'',
|
||||
'Go early to Fushimi Inari, take JR Nara.',
|
||||
'• Tickets (https://example.com/t)',
|
||||
'• https://example.com',
|
||||
'Bring water',
|
||||
'',
|
||||
'Stop, Time',
|
||||
'Shrine, 08:00',
|
||||
'**kept**',
|
||||
'',
|
||||
'Done.',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-AI-SHARE-002: a link whose text is its address stays one address', () => {
|
||||
expect(markdownToText('[https://trek.app](https://trek.app)')).toBe('https://trek.app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatTextOf', () => {
|
||||
it('FE-AI-SHARE-003: adds only the applied changes, under a heading, when asked to', () => {
|
||||
const msg = message([
|
||||
{ type: 'text', text: 'I added **two** places.' },
|
||||
...change('c1', 'Kinkaku-ji', { approved: true }),
|
||||
{ type: 'data-trek-applied', data: { toolCallId: 'c1', entity: 'place', ids: [9], dayId: 3, undoUntil: null } },
|
||||
...change('c2', 'Ryoan-ji', { approved: false }),
|
||||
...change('c3', 'Ginkaku-ji', {}),
|
||||
]);
|
||||
expect(chatTextOf(msg, { includeChanges: true, t })).toEqual({
|
||||
text: 'I added two places.\n\nApplied:\n✓ <assistant.change.verb.addPlace>: Kinkaku-ji',
|
||||
truncated: false,
|
||||
hasChanges: true,
|
||||
});
|
||||
expect(chatTextOf(msg, { includeChanges: false, t })).toEqual({
|
||||
text: 'I added two places.',
|
||||
truncated: false,
|
||||
hasChanges: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-SHARE-004: an answer without text or applied changes has nothing to share', () => {
|
||||
expect(chatTextOf(message(change('c1', 'Kinkaku-ji', {})), { includeChanges: true, t })).toEqual({
|
||||
text: '',
|
||||
truncated: false,
|
||||
hasChanges: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-SHARE-005: a long answer is cut at a word end within the limit and marked', () => {
|
||||
const words = Array.from({ length: 1200 }, (_, i) => `word${i}`).join(' ');
|
||||
const { text, truncated } = chatTextOf(message([{ type: 'text', text: words }]), { includeChanges: false, t });
|
||||
expect(truncated).toBe(true);
|
||||
expect(text.length).toBeLessThanOrEqual(SHARE_MAX_CHARS);
|
||||
expect(text.endsWith('…')).toBe(true);
|
||||
expect(words.startsWith(text.slice(0, -1))).toBe(true);
|
||||
expect(words.charAt(text.length - 1)).toBe(' ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { AssistantMessage } from '@trek/shared';
|
||||
|
||||
import { messageBlocks } from './assistantParts';
|
||||
|
||||
/**
|
||||
* What an answer says once it goes to the trip chat, which shows plain text:
|
||||
* the Markdown read as text, optionally a line per change the user applied,
|
||||
* cut to fit a chat message. Pure, so the preview shows exactly what is sent.
|
||||
*/
|
||||
|
||||
/** Room left below the chat's 5000 characters, so the cut and its mark always fit. */
|
||||
export const SHARE_MAX_CHARS = 4800;
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** A table's divider row (`|---|:--:|`), which has no words. */
|
||||
const TABLE_RULE = /^\|?(?:\s*:?-+:?\s*\|)+\s*:?-*:?\s*$/;
|
||||
|
||||
/** One table row as its cells in a line. */
|
||||
function tableRow(line: string): string {
|
||||
return line
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** The marks of one line of running text: emphasis, code, links and pictures. */
|
||||
function inline(line: string): string {
|
||||
return line
|
||||
.replace(/!\[([^\]]*)\]\([^)\s]*\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text: string, url: string) => (text === url ? url : `${text} (${url})`))
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/__([^_]+)__/g, '$1')
|
||||
.replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, '$1$2')
|
||||
.replace(/`([^`]+)`/g, '$1');
|
||||
}
|
||||
|
||||
/** One line of Markdown as text, or null for a line that only carries layout (a fence, a table rule). */
|
||||
function plainLine(line: string): string | null {
|
||||
if (/^\s*(?:```|~~~)/.test(line)) return null;
|
||||
if (TABLE_RULE.test(line.trim())) return null;
|
||||
const heading = /^\s{0,3}#{1,6}\s+(.*)$/.exec(line);
|
||||
if (heading) return inline(heading[1] ?? '');
|
||||
const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
||||
if (bullet) return `${bullet[1] ?? ''}• ${inline(bullet[2] ?? '')}`;
|
||||
const quote = /^\s*>\s?(.*)$/.exec(line);
|
||||
if (quote) return inline(quote[1] ?? '');
|
||||
if (line.trim().startsWith('|')) return tableRow(inline(line));
|
||||
return inline(line);
|
||||
}
|
||||
|
||||
/** Markdown as the chat shows it, as plain text. Code keeps its lines; blank runs shrink to one empty line. */
|
||||
export function markdownToText(markdown: string): string {
|
||||
const lines: string[] = [];
|
||||
let inCode = false;
|
||||
for (const line of markdown.split('\n')) {
|
||||
if (/^\s*(?:```|~~~)/.test(line)) {
|
||||
inCode = !inCode;
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
lines.push(line);
|
||||
continue;
|
||||
}
|
||||
const text = plainLine(line);
|
||||
if (text !== null) lines.push(text.trimEnd());
|
||||
}
|
||||
return lines
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** The text cut to `max` characters at the last line or word end before it, marked with an ellipsis. */
|
||||
function cut(text: string, max: number): { text: string; truncated: boolean } {
|
||||
if (text.length <= max) return { text, truncated: false };
|
||||
const head = text.slice(0, max - 1);
|
||||
const at = Math.max(head.lastIndexOf('\n'), head.lastIndexOf(' '));
|
||||
// A break far back would drop much of the answer; then the cut goes through the word.
|
||||
const end = at > max * 0.8 ? at : head.length;
|
||||
return { text: `${head.slice(0, end).trimEnd()}…`, truncated: true };
|
||||
}
|
||||
|
||||
interface ShareText {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
/** The answer has applied changes, so "Add the changes" is worth offering. */
|
||||
hasChanges: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat message an answer becomes: its text, then, when `includeChanges`
|
||||
* is on, the applied changes in a short list under a heading. Discarded,
|
||||
* failed and expired changes never go along.
|
||||
*/
|
||||
export function chatTextOf(message: AssistantMessage, opts: { includeChanges: boolean; t: Translate }): ShareText {
|
||||
const blocks = messageBlocks(message, false);
|
||||
const body = markdownToText(blocks.flatMap((b) => (b.kind === 'text' ? [b.text] : [])).join('\n\n'));
|
||||
const previews = blocks.flatMap((b) =>
|
||||
b.kind === 'change' ? b.items.flatMap((i) => (i.state === 'applied' && i.preview ? [i.preview] : [])) : []
|
||||
);
|
||||
const lines = previews.map((p) => `✓ ${opts.t(`assistant.change.verb.${p.verbKey}`)}: ${p.subject}`);
|
||||
const heading = opts.t('assistant.share.changesHeading');
|
||||
const changes = opts.includeChanges && lines.length > 0 ? [heading, ...lines].join('\n') : '';
|
||||
const full = [body, changes].filter(Boolean).join('\n\n');
|
||||
return { ...cut(full, SHARE_MAX_CHARS), hasChanges: lines.length > 0 };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AssistantApplied } from '@trek/shared';
|
||||
|
||||
import { showOnMapSearch, showOnMapTarget } from './assistantShowOnMap';
|
||||
|
||||
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
|
||||
toolCallId: 'c1',
|
||||
entity,
|
||||
ids,
|
||||
dayId,
|
||||
undoUntil: null,
|
||||
});
|
||||
|
||||
describe('assistantShowOnMap', () => {
|
||||
it('FE-ASSIST-SHOW-001: a place goes to the place, even when it was put on a day', () => {
|
||||
expect(showOnMapTarget(applied('place', [7], 3))).toEqual({ placeId: 7, dayId: null });
|
||||
expect(showOnMapSearch(applied('place', [7], 3))).toBe('?place=7');
|
||||
});
|
||||
|
||||
it('FE-ASSIST-SHOW-002: a stop or a note goes to its day', () => {
|
||||
expect(showOnMapSearch(applied('assignment', [41], 3))).toBe('?day=3');
|
||||
expect(showOnMapSearch(applied('day_note', [9], 4))).toBe('?day=4');
|
||||
});
|
||||
|
||||
it('FE-ASSIST-SHOW-003: a change with nowhere to point at opens the planner as it is', () => {
|
||||
expect(showOnMapTarget(applied('packing', [2], null))).toEqual({ placeId: null, dayId: null });
|
||||
expect(showOnMapSearch(applied('place', [], null))).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { AssistantApplied } from '@trek/shared';
|
||||
|
||||
interface ShowOnMapTarget {
|
||||
/** The place the change made or changed. */
|
||||
placeId: number | null;
|
||||
/** The day it lives on, for a note or a stop. */
|
||||
dayId: number | null;
|
||||
}
|
||||
|
||||
/** Where "Show on map" takes an applied change: the place when it is one, else its day; nothing for the rest. */
|
||||
export function showOnMapTarget(applied: AssistantApplied): ShowOnMapTarget {
|
||||
const placeId = applied.entity === 'place' ? (applied.ids[0] ?? null) : null;
|
||||
return { placeId, dayId: placeId === null ? applied.dayId : null };
|
||||
}
|
||||
|
||||
/** The planner's query for it: `?place=` opens the place, `?day=` selects the day. */
|
||||
export function showOnMapSearch(applied: AssistantApplied): string {
|
||||
const { placeId, dayId } = showOnMapTarget(applied);
|
||||
if (placeId !== null) return `?place=${placeId}`;
|
||||
if (dayId !== null) return `?day=${dayId}`;
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AssistantApplied } from '@trek/shared';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/** What the parts deep inside a chat need from whoever shows it. */
|
||||
export interface AssistantView {
|
||||
tripId: number;
|
||||
/** 'window' on the desktop, 'fullscreen' on the phone. */
|
||||
variant: 'window' | 'fullscreen';
|
||||
/** How much room the chat has: a narrow window and the phone are 'compact'. */
|
||||
density?: 'compact' | 'roomy';
|
||||
/** Shows an applied change in the planner; the button is left out where nothing can show it. */
|
||||
onShowOnMap?: (applied: AssistantApplied) => void;
|
||||
/** Selects a day in the planner, from its weather; left out where no planner can show it. */
|
||||
onFocusDay?: (dayId: number) => void;
|
||||
/** Brings the map into view where the chat covers it (the phone), before the map moves. */
|
||||
onRevealMap?: () => void;
|
||||
}
|
||||
|
||||
export const AssistantViewContext = createContext<AssistantView | null>(null);
|
||||
|
||||
export function useAssistantView(): AssistantView {
|
||||
const view = useContext(AssistantViewContext);
|
||||
if (!view) throw new Error('useAssistantView outside AssistantSurface');
|
||||
return view;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user