Compare commits

..

97 Commits

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

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

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

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

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

The chat sees the map on screen, offers next questions under an answer,
shows its live step in the window head, previews waiting changes inside the
day list, checks whether a day still works, folds a multi-day plan into a
week grid, keeps per-trip notes and can estimate costs. An empty or cut-off
answer gets one push instead of ending silently, and the assistant's own
step and size limits are raised.
2026-10-21 22:29:57 +02:00
Maurice 214984f398 feat(assistant): trip assistant chat with your own AI connection
Adds an in-app assistant that reads and plans the open trip. Users connect
their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or
OpenRouter key) or use the one the admin set up, including a custom
OpenAI-compatible server. It is an addon and only shows while a connection
is usable.

The chat runs on the existing MCP tools, pinned to the trip. Every change is
held as a change card the user applies or discards, and applied changes can
be undone. On the desktop it is a floating window that can be moved and
resized; on the phone it is fullscreen. Search results show as place cards
that can go onto a day, weather lookups as a strip, proposed places as
ghost markers on the map with a before/after switch, and an answer can be
shared into the trip chat. Booking files can be dropped in and are read
into change cards.
2026-10-21 22:29:56 +02:00
Maurice bb3c0151d5 fix(journey): sort the stay dates by a compare, make the hint pills real buttons and keep the walk's no-boats default a constant 2026-10-21 21:34:53 +02:00
Maurice 3101b36f3d fix(journey,tours): read a day's stays as a read, give the services the request context in the e2e runs and hold the tests to the foot-only walks and the translated notices 2026-10-21 21:16:36 +02:00
Maurice ef4eac34c4 docs(dawarich): word the next-day note of a stay's time range without a dash 2026-10-21 20:55:02 +02:00
Maurice a518b8dfc2 fix(journey): give preparing the recorded days its own section in the journey settings, apart from the trip tracks 2026-10-21 20:54:22 +02:00
Maurice 8523bbb724 fix(journey): say when a stay runs into the next day, leave the doubtful stays of a day unticked and pin a day report to a stay of the day, not the night 2026-10-21 20:46:33 +02:00
Maurice 464d4e006c feat(tours): mark the places along the tour that are a stop of it already 2026-10-21 20:33:12 +02:00
Maurice 1797e1c70d fix(journey): frame the first map view around the entries and the plan, and never hand a removed map the basemap it waited for 2026-10-21 20:33:12 +02:00
Maurice 5dc945145f fix(journey): keep the journey page and maps within their lint gates, format the journey locales 2026-10-21 20:00:07 +02:00
Maurice a1180b2b19 fix(journey): open the map on the plan when nothing else is located, say when the recordings could not be read and open photo hits on the shared map 2026-10-21 19:08:25 +02:00
Maurice d62fc284e1 fix(journey): share the stays of a day report only with the recorded days, and hold the share read tests to the two new flags 2026-10-21 19:08:25 +02:00
Maurice 8f53d98d45 feat(journey): show the plan, what was recorded, the entries and the photos as layers of the journey map
Every journey map (the desktop aside, the phone screens and the shared page)
now carries four switches: Plan, Recorded, Entries and Photos, each with its
count, a spinner while it loads, the warning tone when it could not be read
and the reason when it has nothing to show. A switch only hides, in place:
the map is not rebuilt and keeps its view, and the choice is kept in this
browser per journey (per link on a shared page, by a hash of its token).

The plan draws the places the linked trips put on their days as hollow dashed
pins and the trips' GPX routes dashed; a place an entry was written about is
not drawn twice, the entry's pin carries a check instead. The stays a day
report was taken over from show as dots in the colour of their day. Marks on
one spot join into a disc with their number and open as a list; a picked
place or stay opens a card with its trip, days and times and leads to its
entry. A key beside the switches explains every pin and line and says that
a plan is no proof of a visit. The phone timelines now show the photos too.

The GPX routes come with the map's layers through the linked trips, read
only while the journey's track switch is on; the frozen /tracks answer stays
Studio's. The share settings on desktop and phone get Trip plan and Recorded
routes under Map, off by default and only switchable while the map is shared.
2026-10-21 18:55:54 +02:00
Maurice 9b71948497 feat(journey): read what a journey map shows beside its entries, and share its plan and recorded days on purpose
GET /api/journeys/:id/map-layers answers the planned places of the linked
trips (one per place, with every day the plan puts it on and the entry
written about it), the trips' GPX routes while the track switch is on, and
the located stays entries were taken over from. It goes through
journey_trips, so a trip nobody wrote about yet shows its plan too. Every
source is read on its own: one that fails says unavailable and the others
still draw. A position that is not one is left out, never moved.

GET /api/public/journey/:token/map-layers cuts the same on the server by the
link: nothing while the map is not shared, the plan and its routes only with
share_plan, the recorded days only with share_recordings and only beside a
published entry, stays only of published entries. Migration 046190 adds both
flags to journey_share_tokens, off by default, so a journey that is public
already shows what it showed before. The share link REST route and the MCP
share tool take them alike; get_journey_map_layers reads the layers over MCP
on the same access, a route by its size only.
2026-10-21 18:15:24 +02:00
Maurice bb10b94046 fix(journey): mark only the days a run reaches as prepared, name trips out of reach, show editors their own status in the day review and say when a fetch is already running 2026-10-21 17:53:39 +02:00
Maurice 94c369b913 feat(journey): prepare the recorded days of a journey's trips in the background, switched on per journey, with each day's state
Journey Settings get "Prepare recorded days automatically" under GPX Tracks,
off by default and set by the owner. With it on, the Dawarich background
check also reads the linked trips of the journey for the owner and every
editor with a connection of their own, within the same 400 day reach and
windows as the trip sync, and only for new and changed stays: a trip the
person travels on is not asked twice, and the stays of a trip they are not
on are filed under no trip. Nothing is written into the journey; the stays
wait at their day, and a stay taken over or left out is never offered again.

Under the switch the caller sees when their stays were last fetched, or why
not (no connection, no dated trip, background check off, the reason of the
last failure beside the state still shown), with a "Fetch now" that the
switch also runs when it is turned on. A day of the trips that is not final
yet carries a dot on its take-over button, on the phone in the stay sheet,
and the tooltip says whether it is still running, was fetched before it
ended, was not fetched yet or shows an older state after a failed refresh.
The day review says so for today too.

Server: migration 046180 adds journeys.auto_prepare_recordings and
journey_recording_prep (the last attempt, last success and last error per
journey and person). JourneyRecordingPrepService in the journey domain lists
the prepared trips and keeps the status; DawarichSyncService walks those
trips in the same loop as the person's own and records each journey's
outcome. GET /api/journeys/:id/recordings/prep answers the caller's status
under a @trek/shared contract, the MCP tool get_journey_recording_prep reads
the same, and update_journey takes auto_prepare_recordings like the PUT
contract. The column stays out of the plugin output. All 27 locales carry
the strings; the Journey, Dawarich and MCP tool wiki pages describe it.
2026-10-21 17:39:33 +02:00
Maurice 3f1d1a57cc fix(journey): take a day over from the phone stay sheet, keep a picked position inside its group and send stays without a place unpinned
A journey on a phone with stays on days that hold no entry offers each waiting day in the Dawarich stay sheet, so a day can be built from its stays alone; desktop and phone read the same days through openStayDatesWithin. A position picked for a group falls back to the longest stay with a place once that stay is unchosen or split off, a merged entry of stays without a position goes out unpinned instead of being refused, and a stay taken over meanwhile is never joined across. review_journey_day_stays is no longer marked read-only, since it refreshes the caller's stored stays. The already-taken notice no longer claims the stays were in this journal. An e2e case checks the drafts stay off the public share.
2026-10-21 16:49:47 +02:00
Maurice 7c1de6ed82 feat(journey): take a recorded day over with its stays, chosen, renamed and joined into drafts or one day report
A journey day now has a list button in its header beside the route, with the
number of stays still waiting, and the row of the day's Dawarich stays gets a
"Take over day" button; on a phone the journey's More options offers it for
the day of the card shown. The dialog reads the day again from the caller's
own Dawarich and shows the route and the stays on one map, numbered in the
order they were lived and pinned in the colour of the day, beside a list with
times, durations, the stays Dawarich only guessed or is unsure about, the ones
without a position, the entries the journal has on the day and how many people
will see the drafts. When Dawarich does not answer, it says why and shows the
stays of the last sync.

Open stays start chosen, the same place twice in a row starts joined as one
entry under a title and a chosen map position, any two neighbours can be
joined or kept apart, a name can be corrected and a stay left out. "Day
report" turns every chosen stay into one entry that lists them with their
times, at a chosen stay or off the map. The day's route can go along when the
journey keeps none yet. Nothing is written before "Take over N drafts", and a
blank name or title holds it back with the reason. Stays left out are dismissed
and can be restored in the stay list.

The take-over writes drafts only, never touches what the journal holds, and is
one transaction: every stay is checked to be the caller's and of that day, the
entries, the stays they hold and the marked suggestions commit together, and
the entries are announced after the commit. A stay the journey holds already
is reported instead of written twice, so a retry after a lost answer is
harmless; a failed attempt reads the day again and keeps the choices, and a
route that fails after the drafts can be kept again alone.

Server: migration 046170 adds journey_entry_stays (one row per stay of an
entry with its own name, place and times, unique per journey, source, person
and visit, gone with its entry), owned by the journey domain through
JourneyEntryStaysService. The single stay accept records its stay there too,
over REST and MCP alike. DawarichSyncService.refreshDay reads one day's visits
and upserts them without flagging anything missing; DawarichDayStaysService
lists and settles a day's stays. JourneyDayImportService in journey-recordings
serves POST /api/journeys/:id/recordings/:date/stays/review and /import with
@trek/shared contracts, and the MCP tools review_journey_day_stays and
take_over_journey_day_stays run on the same service and checks. createEntry
can write without announcing for a caller that announces after its commit.

The desktop dialog and the phone sheet of the route (JRN-01) and of the
take-over share one frame each. All 27 locales carry the strings; the Journey
and Dawarich wiki pages and the MCP tool list describe it.
2026-10-21 16:31:40 +02:00
Maurice fbf6cd2840 fix(journey): read a day kept meanwhile again to offer replace, date the phone row in the app language 2026-10-21 15:18:01 +02:00
Maurice 5116f7be2c fix(journey): refuse the second of two saves of a recorded day at once unless it asked to replace 2026-10-21 15:18:01 +02:00
Maurice 4f60cc5c98 fix(integrations): cut a journey day in the zone of its own first track, never in the UTC Dawarich writes times in 2026-10-21 15:18:00 +02:00
Maurice 65bb2d95eb feat(journey): keep the route recorded on a journey day from Dawarich, previewed first, with its gaps
A day header in the journey timeline now carries the Dawarich mark for an
editor whose Dawarich answers (dimmed with the reason while offline). It opens
a dialog that names the journey and the day and reads that one local day from
the caller's own connection: a sketch of the route, distance, first and last
time, pieces, gaps without recording, modes and whether tracks or thinned
points answered. The day is cut in the zone Dawarich records in where its
answer shows one, else in this device's, and a picker cuts it in any other.
Nothing is kept before "Keep route". On a phone the same sheet opens from the
journey's More options for the day of the card shown.

What is kept is the preview that was seen: the save reads the day again and
refuses with recording_changed when its hash moved, and the dialog shows the
new version. The same day taken over twice keeps one version and announces
nothing; a different version is replaced only on "Replace", never touching an
entry or text. A failed read throws before any write, so the kept version
stays. Gaps stay gaps: points are split where silence ends elsewhere, and no
day is joined to the one before. The dialog says how many other people will
see the route; editors may keep their own recording.

Kept days show in the day header (distance, whose recording, since when,
still running) and on the desktop and phone journey maps in the colour of
their day's pins, winning over the live trail of their date, and stay drawn
without Dawarich. A trip's GPX track is now drawn dashed as the plan, recorded
routes solid. Shared links do not show recorded days. Remove route asks first
and works without a connection.

Server: migration 046160 adds journey_recorded_days (one row per journey,
local day and source, segments as JSON, zone, window, fetch time, hash,
importer kept past their account). The journey domain owns the rows through
JourneyRecordedDaysService; the new journey-recordings domain takes a day
over through DawarichTracksService.fetchDay, which reads past the cache, asks
a window wide enough for every zone and refuses with DomainErrors. REST under
/api/journeys/:id/recordings (list, preview, PUT and DELETE /:date) with
@trek/shared contracts, and MCP list_journey_recorded_days,
preview_journey_recorded_day, save_journey_recorded_day and
delete_journey_recorded_day on the same service and access. A new user event
journey:recording:updated keeps every open map current. JourneyTrack gains an
optional kind. All 27 locales carry the strings; the Journey and Dawarich wiki
pages and the MCP tool list say what it does.
2026-10-21 15:05:21 +02:00
Maurice cbca3b2851 test(maps): the overpass place names cases get a suite of their own, the service suite back under its size 2026-10-21 13:53:24 +02:00
Maurice 8a75712041 fix(tours): tell a router that did not answer from a walk no way joins, count each boat of a leg on its own into the day and its arrivals 2026-10-21 13:53:21 +02:00
Maurice 6e13f7e655 fix(tours): keep a city walk on foot across the water, mark a crossing by boat apart from the walking time and save it only once taken 2026-10-21 13:16:43 +02:00
Maurice 41f3267503 fix(tours): name every derived field of a walk's remaining time, read a second run on the same day, say when only the way back is left 2026-10-21 12:35:02 +02:00
Maurice 2a4a5617dc feat(tours): show the walking time left beside a city walk's progress, with what it counts from and the stays apart 2026-10-21 12:26:36 +02:00
Maurice 43353b9394 feat(tours): read the walking time left of a city walk on a day over MCP, apart from its stays and the whole walk 2026-10-21 12:16:57 +02:00
Maurice 4eaf5e6a11 fix(tours): read e-bike chargers as mapped, keep unnamed ones on the map search 2026-10-21 12:05:38 +02:00
Maurice 1a722e63fb feat(tours): find e-bike charging points along a bike tour, only those tagged for bicycles, with what they state 2026-10-21 11:58:39 +02:00
Maurice 924e38472e fix(maps): stretches beside the overpass probe wait for it, empty note only after a full search 2026-10-21 11:43:04 +02:00
Maurice bf2efca8f0 fix(tours): let a failed search along the tour really ask again, with a probing breaker, hedged mirrors and a countdown 2026-10-21 11:35:03 +02:00
Maurice 2377a63a05 fix(tours): offer the place's undo only while its insert is the last history step, and explain the add button with a tooltip 2026-10-21 11:14:39 +02:00
Maurice 377619abd1 fix(tours): keep the place card open while its position is picked, with slots, a map preview and undo 2026-10-21 11:08:46 +02:00
Maurice 988890866a fix(ski-routing): sort the network cache key by code unit, not by default 2026-10-20 23:18:54 +02:00
Maurice 0dda5bc3e9 fix(tours): name the piste router switches and the stage message keys where the key scan sees them 2026-10-20 23:06:29 +02:00
Maurice 7fa8b3fe35 fix(tours): keep the day menu items with the stage logic their views import
The pair rule wants TourDayAddButton to import stageSpread; the items it
builds belong there anyway. The addon manager test answers the Tours
switches, so a failed list is the only thing that toasts.
2026-10-20 22:54:57 +02:00
Maurice 112017a9e8 fix(tours): clear the stage spread's lint, read the vias after render and test against the reloaded vias
The vias the spread keeps on their legs are taken over in a layout effect
instead of during render, the route redraw takes the planner's async
updateRouteForDay, and the menu items for whole or stages move out of the
button component into their own module. Two leftovers of the old day pills
in tourParts go. The hook test's reanchor reloads the vias like the real
one does, and the detail test finds the button by its label, Add to Day.
2026-10-20 22:44:42 +02:00
Maurice cb5c5ab4a1 chore(client): lower the planner page size baseline to its count 2026-10-20 22:38:29 +02:00
Maurice bed9f53615 fix(deps): raise the MCP SDK to 1.32 and hono to 4.13.11
GHSA-22jm-h49p-29qw and GHSA-6prh-2h8m-c8cw (MCP SDK, high) and
GHSA-5r4p-p66f-jhc7 (hono, moderate) are fixed in these versions. npm audit
for what the server installs reports none left.
2026-10-20 22:29:33 +02:00
Maurice a8e2486cd3 test(tours): read the last undo without Array.at, which the client lib does not offer 2026-10-20 22:23:45 +02:00
Maurice 5d5015066f chore(planner): keep the planner page at its size after the stage choice 2026-10-20 22:17:53 +02:00
Maurice 3e8d192305 fix(tours): keep the road trip's vias on their legs when stages spread, and ask on the phone too
Spreading a multi-day tour wrote through the store directly and skipped
the via re-anchoring every other write on the plan does. A tour with a
start counts as a road-trip stop, so taking the whole tour, a stale stage
or a moved stage off the middle of a day shifted every later stop, and the
vias anchored by position shaped the wrong leg. The undo had the same gap
when it put the whole tour back mid-day. Each stop a spread or its undo
takes off a day now carries the day's vias along (reanchorAfterRemove),
and the whole tour put back carries them the other way
(reanchorAfterInsert). The stops are read from the store as each write
goes out and the vias are kept as the last correction left them, since
one run can touch a day twice before the plan renders.

The phone's place sheet greyed out a day that holds a multi-day tour, so
its stages could not be spread from there. It now checks the day the way
the desktop menus do and leaves the choice to the plan, which opens the
card with only the stages offered.

The second step of the day menu names the day picked, and a greyed-out
"Whole tour on this day" says why right under it. The toast for a tour
that sat whole on several days now says "days" instead of "day", in all
27 languages.
2026-10-20 22:17:38 +02:00
Maurice bafb74f872 feat(tours): put a multi-day tour on a day whole or a stage a day from it on
Putting a multi-day tour on a day through any of the usual ways put the
whole tour on that one day. Now the way it goes on is a choice: the whole
tour on this day, or stage 1 on this day and each next stage on the next
day of the trip.

The day menu of the tour lists and the detail's "Add to day" ask in the
same menu, as a second step after the day; a tour dragged onto a day, or
added from a place where no menu asked, gets a small dialog on the desktop
and a card on the phone. Only the trip's days are used: when fewer days
are left than stages, the menu says up to which stage fits and the rest
stay without a day. A stage already on a day moves to its new day, the
whole tour comes off the days it was on, and a stage the route no longer
has comes off too. The writes run in order through the store, the repo
and the mutation queue, so they work offline, and one undo takes the
spread back, the whole tour onto its day again.

The plan lives in stageSpread.ts, the writes and the question in
useTourDayChoice, which every way onto a day now passes through on both
the desktop and the phone.
2026-10-20 22:17:20 +02:00
Maurice b71e32cf12 fix(tours): clear the last lint, knip and wiring findings of the follow-up
The unused day pills go, a client-only type stops being exported, the place
form context moves out of the page hook so its view model stays the first
object it returns, the place form test helpers split the stub component
from the plain setup, and four router files are formatted.
2026-10-20 22:13:25 +02:00
Maurice 786df612fd refactor(planner): split the tour day time growth out of four size-capped files
The place form's time section moves into PlaceFormTimeSection, its tour test
into PlaceFormModal.tour.test.tsx over a shared tests/helpers/placeForm setup,
the page hook hands the place form its close, day plan and tour, and the day
plan's surface classes and tones move into dayPlanSurfaces. The size baseline
drops to the new counts.
2026-10-20 22:02:48 +02:00
Maurice e6002444a1 test(server): narrow a partial ways answer the way the server build can, format three tests 2026-10-20 21:56:06 +02:00
Maurice e453816ded test(shared): count the five tours.ski.summary keys in the tours domain 2026-10-20 21:43:48 +02:00
Maurice b9ca822d51 chore(shared): format the ski schemas and the new CJK tours strings 2026-10-20 21:43:47 +02:00
Maurice 20537193b6 test(tours): expect ski_options null on non-ski rows, let the ski choice settle before asserting 2026-10-20 21:43:47 +02:00
Maurice 60611aee65 test(server): run sequential awaits through oneByOne instead of a loop 2026-10-20 21:43:47 +02:00
Maurice 6c66cff365 fix(ski-routing): no promise in a condition and no await in a loop, ski route type from its schema
ski-areas.service checks the pending load and download against null and
undefined, the area refresh lane recurses instead of awaiting in a loop,
TourSkiRoute is inferred from its schema, the ways answer is read by
complete === false, and /v1/routes no longer needs a truncated field.
2026-10-20 21:43:46 +02:00
Maurice d3f574f225 fix(tours): match a saved ski route by its ski choice, and keep the planner under the lint limits
The saved ski route compared against an undefined skiKey, so opening a saved
ski tour threw. The held seed now lives in useSavedSkiSeed and is matched by
skiRouteKey(skiChoice). The ski route facts move to skiRouteFacts.ts so the
summary and leg rows only export components, the rail renders
SkiPlannerSummary, and openTour, useTourRoute and useAddonManager shed the
branches and lines the new ski code had added.
2026-10-20 21:43:46 +02:00
Maurice e5c5ffbc00 fix(tours): one get_tour_plan description for routed ski legs, entities as generated
A ski leg is routed where the piste router planned it and free elsewhere;
the description said both. The collection tours entity takes its generated
form again after the merge.
2026-10-20 21:17:08 +02:00
Maurice 562176bd83 chore(db): number the ski options migration after the Tours default 2026-10-20 21:16:09 +02:00
Maurice ae699187f7 fix(tours): open a saved ski tour as saved and keep what its router planned
A saved ski tour now opens with the line, heights, time, build and
planned legs it was saved with, read from the place this device keeps,
and nothing is asked of the piste router until a point, an option or the
discipline changes. Opened offline, while TREK Places is down or with
piste routing switched off, it no longer turns every leg straight, and a
rename saves the planned tour as it was. A kept line whose length is not
the tour's is another save's, and the tour is planned afresh.

- the save names the legs the router planned (ski_planned_legs, checked
  against the tour's own legs); get_tour_plan reports each leg routed or
  drawn from them instead of from the build alone, a tour that names none
  counting as planned only with the router's time
- copies keep ski_options in every path: in the trip, taken over, kept
  in a collection and made from one (collection_tours gets the column in
  the same unshipped migration)
- the route summary shows the data build, the km of runs, the height
  skied down on them and the lift rides, and the credit the server sends
  in place of a fixed one; a tour opened as saved says so
- answers refused while piste routing is off, or planned on an older kept
  copy, are not remembered, so they are asked again later
- terrain heights between known ones are filled in one walk along the line
- i18n: five summary keys in all 27 locales
2026-10-20 21:15:45 +02:00
Maurice 1d72fbd25f fix(ski-routing): keep one job's timeout to itself and bound the area refresh
The router thread now takes one job at a time from a queue kept on the
main thread, so a job's clock starts when the thread takes it up. A job
that times out ends only its own thread, and only while that thread is
still the current one; the jobs in line go to a fresh thread instead of
waiting out their own timers and killing the new thread in turn. A
thread that dies fails the job it held, and a thread that cannot start
fails each job on its own.

- the daily refresh fetches changed areas two at a time and stops once
  the TREK Places breaker opens; the rest keep their older copies
- the winter index cap is on the unpacked JSON fetch hands over, so it
  follows from the host's 8 MB gzip cap (64 MB) instead of matching it
- a build name is read to the rule a ski tour stores it by, so a build
  TREK could not save is refused when the winter layer is read
- an unused import in the piste router contract is gone
2026-10-20 21:15:21 +02:00
Maurice 2d0c0597b3 feat(tours): plan ski tours on the piste router in the planner
A ski tour with a discipline now sends its legs to the piste router and
takes back runs, lifts and trails: each stretch is coloured by its grade
in the area's scheme, links and lift rides down are dotted, and a leg the
router cannot plan stays straight and unrouted with its reason and the one
change that would plan it. Snowshoe and winter hike legs off the piste
data follow summer paths instead, said so. The router's time and heights
are used once every leg is planned, and the options, the build and the
time are saved with the tour.

- skiRouteRepo and api: one request per tour, batched, remembered unless
  the answer may change, offline legs failed as offline
- planner: router options per discipline, leg steps and failure rows with
  fixes, a piste route summary, draft and reopen keep the options
- admin: switch for the piste router under Tours
- i18n: new ski keys in all 27 locales, ski info and time texts rewritten
2026-10-20 21:15:07 +02:00
Maurice a85a50e186 feat(tours): keep a ski tour's piste router options, build and time
A ski tour now saves the options its legs were planned with and the piste
data build they ran on, as one JSON column (a new migration, since the
ski migrations have shipped on this branch); the server keeps only the
options its discipline reads. Both stay off every other type. The refusal
of a ski time is lifted: the piste router works one out, and the planner
sends it only when every leg followed the piste data. get_tour_plan names
the legs of a tour the router planned as routed, and list_tours says what
the new fields hold.
2026-10-20 21:14:44 +02:00
Maurice c8eb510bd4 feat(ski-routing): plan ski tours on the piste graphs of TREK Places
A new Nest domain plans the legs of a ski and winter tour on the runs,
lifts, cross-country trails, ski touring and winter paths the Places host
builds from OpenStreetMap, one SGR1 graph per ski area. TREK fetches the
index once, downloads an area by its id when a planner first plans in it,
checks it against the index's SHA-256 and keeps it in data/ski; no
clicked point ever leaves the server. A leg whose area is slower than two
seconds answers loading, and an older copy stands in while the host is
down.

The reader decodes the writer's golden file byte for byte, including the
two edge flags it added (area_has_run, type_unknown). The search is A* in
a worker thread over a profile per discipline (alpine, snowboard,
cross-country by technique, touring, snowshoe, winter hike) with the
times and penalties of the cost model, up to five snap candidates per
point, lift stations as entry points, legs that go on from each other,
pieces of a split network stitched by their boundary nodes, other ways by
the penalty method, and a failed leg explained by relaxing one rule at a
time (difficulty, lift class, technique, riding down, uphill) without ever
handing out the relaxed line.

POST /api/trips/:tripId/ski/route, GET .../ski/area-near and
GET .../ski/network/:areaId sit behind Tours and the trip, with
plan_ski_route as their MCP twin. A daily job looks for a new build and
sweeps areas unused for 90 days. The admin switch ski_routing_enabled is
on wherever TREK Places is.
2026-10-20 21:13:48 +02:00
Maurice beea4c762b refactor(server): move the routing heap and the metre helpers out of the paddle domain
The binary heap and the flat-frame distance helpers are not about water;
the piste router needs the same two. They move to src/lib/geo, where any
domain may import them without reaching into paddle-routing. Nothing
changes in what they do.
2026-10-20 21:12:15 +02:00
Maurice d924dcf99d fix(tours): keep the Places layers off the search breaker and keep partial way answers
The ways and routes layers now count failures on a breaker of their own, so
busy or slow layer answers no longer send place search to the fallback for a
minute, and a layer's 404 no longer clears the count of search failures.

When a later batch of /v1/ways fails, the batches that answered are kept and
only the ids still open go to the Overpass mirrors.

The wiki, the compose file and the chart now say that the way ids of a planned
tour and the horse routes box go to the TREK Places API.
2026-10-20 21:12:09 +02:00
Maurice 371083ed47 feat(tours): read way tags and horse routes from the TREK Places layers
The trail grades under a tour (BIK-05, MAP-03, MAP-04, RUN-02) and the
horse routes around a map view (HOR-01) asked the public Overpass mirrors.
The places host now serves both from its own planet build: GET /v1/ways
for the tags of ways by id and GET /v1/routes for route relations per box,
each in Overpass element form.

trek-places.client gains trekPlacesWays (batches of 500, one after
another) and trekPlacesRoutes, both through getJson with its off switch,
breaker, timeout, size cap and instance header, each element validated on
its own. TrekPlacesLayersService in the maps domain hands their answers to
the tours services, or why there is none (switched off, layer not built,
failed), and logs that once per layer until it answers again. Both tours
services ask the layer first and fall back to the existing Overpass path;
the responses TREK sends its clients are unchanged.
2026-10-20 21:12:09 +02:00
Maurice eaeaf68352 chore(server): format the tours MCP test 2026-10-20 21:11:44 +02:00
Maurice 5156fa2c0a test(addons): check the Tours migration against the addon table as it was before
The table holds the seeded addons too, so compare against a snapshot taken before the migration with only tours switched on.
2026-10-20 21:11:44 +02:00
Maurice 6c6e285c3d test(tours): expect the time estimate on imported tours and hikes without a time
Since the estimate rule, an import without its own time shows the worked-out estimate on the day plan tile, and the sidebar metrics lead with it. Add the estimate field to the RS-01 fixture.
2026-10-20 21:11:43 +02:00
Maurice 93e1156aa4 refactor(tours): bring the tour time parts, collection rows and flyover back under the lint baseline
Move workedOutEndHint and totalText into tourDayTimeHint.ts so TourDayTimeParts only exports components, render the collection row's type icon without a component made during render, and pass the chase camera's lens and scene as one view object.
2026-10-20 21:11:43 +02:00
Maurice dc19807126 fix(tours): zoom the flyover around the rider and keep no stale GPX file
The flyover asked wheel and pinch to zoom around the centre with enable()
on handlers that were already on. MapLibre returns from enable() at once
in that case, so the option never took and a wheel zoom still went to the
cursor before the camera pulled back to the line. Each handler is now
switched off and on again with the centre anchor, and the same way back to
the cursor on pause, stop and unmount. The map stub refuses an enable() on
a handler that is on, like MapLibre does, so the tests see the real anchor.

The GPX file kept for the share sheet was only dropped when the tour's id
or name changed. A route saved elsewhere arrives live with a new revision
and the dialog kept handing out the old file. The kept file now carries the
revision it was read at; a new revision drops it and fetches again, and a
file a tap kept is not shared for another revision.
2026-10-20 20:56:19 +02:00
Maurice 5580ff71a9 feat(addons): Tours is on for every install
A fresh install gets Tours enabled from the addon seeder. An install that
already has the row is switched on by a migration that runs once, so an
admin who turns Tours off afterwards keeps it off.
2026-10-20 20:56:19 +02:00
Maurice e42622d71d feat(tours): let the user zoom during the 3D flyover
The chase camera set a fixed zoom and pitch on every frame, so wheel,
pinch and the +/- keys were undone at once. It now keeps the zoom and
tilt of the user's latest gesture and only follows position and
bearing. While a zoom is still easing the flyover holds for those
frames, since any camera move would cancel the gesture. Wheel and pinch
zoom around the rider while flying, and drag panning is off until the
flyover stops. Starting over returns to the flyover's own zoom.
2026-10-20 20:56:19 +02:00
Maurice 94674e7572 fix(tours): open the GPX share sheet inside the tap
Sharing a tour fetched the GPX file first and only then called
navigator.share, so the tap that asked had often run out by then. The
retry path kept that file for one more tap only, and a tap while a sheet
was still open made the browser throw InvalidStateError, which read as a
failed share.

The share button now fetches the file ahead where sharing is offered
(not while the planner has unsaved changes), and every tap hands a fresh
File built from the kept bytes to the sheet before anything is awaited.
A file a tap had to fetch is kept for the next tap. A tap while a sheet
is open is let be, and InvalidStateError and a closed sheet stay quiet;
only a real failure still says the download is left.
2026-10-20 20:56:19 +02:00
Maurice 7f14dde362 fix(tours): tidy the day times of a tour after review
The get_tour_plan description named the estimate of a tour "such a field"
right after the values of a recording, so it could be read as one of them.
It now follows the sentence on derived fields and says outright that it is
one of them, worked out from distance and heights or the pace of a run.

The arrow between a tour's start and end points from the start to the end
in a right-to-left language as well. A tour just added to a day, which
carries no tour_place_id until the next refresh, now says its overrun like
the row above it. A phone row of an import with a total the travellers
entered shows that one time, the estimate joining its words as on the
desktop. The German overrun line reads naturally without an item name.
2026-10-20 20:52:31 +02:00
Maurice 9765cf3fee feat(tours): give a tour its start and end in the day plan
A tour on a day had no place in the day's timeline: its time was not tied
to the slot it was planned in, so several hikes on one day could not be
lined up. A tour now sits there the way a place does, on the times of its
day entry, one model for every tour type. With a start and a known time
(the planned total, else the estimate of an import) its end follows as the
start plus that time; it is shown fainter, marked with an approximate sign
where it comes from an estimate, and says in its tooltip how it was worked
out. An end typed by hand wins. A day that holds one stage of a multi-day
tour gets no end from the whole route.

When the end runs past the start of the day's next timed item, the row
says so in one quiet line, as a late connection between two city walks
does; nothing is moved. The existing time fields of the day entry, on the
desktop form and the phone sheet, say what the end works out to while none
is typed. City walks keep their arrivals per station. The logic lives in
one module both day plans run on.
2026-10-20 20:52:30 +02:00
Maurice c38eb7ebf0 feat(tours): estimate the walking time of imported tours
A hike imported from GPX, KML/KMZ or wanderer had no time at all, so
several hikes on one day could not be planned against each other. It now
gets the walking time the planner times a hike by (DIN 33466), worked out
from its own distance, ascent and descent; a run gets its distance at the
pace entered. Without heights in the track the time is unknown rather
than the time on the flat, and types timed by a router, by a speed of
their own or by nothing get no estimate.

The estimate is derived on every read and never stored, so tours imported
before get it too and no stored or entered time is touched. The rule moved
to @trek/shared, where the planner, the client and the server read it.
The tour list, the day rows on desktop and phone and the tour detail say
it as an estimate, apart from a planned total; list_tours and
get_tour_plan carry it as time_estimate and name it as derived.
2026-10-20 20:52:30 +02:00
Maurice e653733390 fix(notifications): a little more room for the unread count, with a test 2026-10-20 20:52:23 +02:00
Maurice 2655e510fc fix(notifications): give the unread count room in its badge 2026-10-20 20:48:33 +02:00
Maurice 904eb31e62 fix(tours): even the tour detail's rows and footer
The times fill their row when there are two, the trip days are boxes in
the look of the measures with the date under the day, the footer's
collection button carries its label like the others and the copy button
says Copy, and the name field keeps its distance to a save button as large
as the close button.
2026-10-20 20:48:33 +02:00
Maurice 76ec302526 feat(tours): kept tours are rows of a collection's list, on the phone too
A tour kept in a collection was a card in a box of its own above the list,
and its button lost its look to the collections page's button reset. It is
now a row of the list like a saved place: its grade or type tile, its name,
type and the trip it came from, its measures, and Add to a trip, with
removing dimmed beside it. The list's search narrows the tours too. The
phone shows the same rows and takes a tour into a trip from a sheet, on the
same hooks as the desktop.
2026-10-20 20:48:33 +02:00
Maurice 810feba413 fix(tours): open the tour dialogs in the middle of the screen
The walk from a Google Maps link, the take-over from another trip and the
other new tour dialogs sat at the top of the screen; they now open centred
like the planner's own dialogs.
2026-10-20 20:15:05 +02:00
1123 changed files with 108525 additions and 3430 deletions
+3
View File
@@ -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
+2 -2
View File
@@ -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.
+1
View File
@@ -46,6 +46,7 @@
"predev": "node scripts/patch-maplibre.mjs"
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
+5 -5
View File
@@ -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,
+62 -3
View File
@@ -16,12 +16,17 @@
"feature": "Addon manager",
"hooks": [
"src/components/Admin/addons/addonModel.ts",
"src/components/Admin/addons/useAddonManager.ts",
"src/components/Admin/addons/useLlmParsingConfig.ts"
"src/components/Admin/addons/useAddonManager.ts"
],
"desktop": ["src/components/Admin/AddonManager.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
},
{
"feature": "Addon manager: AI tile",
"hooks": ["src/components/Admin/addons/useAiAddonTile.ts"],
"desktop": ["src/components/Admin/addons/AiAddonTile.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
},
{
"feature": "Admin notification preference matrix",
"hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"],
@@ -58,6 +63,21 @@
"desktop": ["src/pages/admin/AdminUsersTab.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminUsersSection.tsx"]
},
{
"feature": "AI assistant chat",
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
},
{
"feature": "Assistant hints",
"hooks": ["src/components/Assistant/useAssistantHints.ts"],
"desktop": ["src/components/Assistant/AssistantHeader.tsx", "src/components/Assistant/AssistantLauncher.tsx"],
"mobile": [
"src/mobile/screens/trip/assistant/MAssistantScreen.tsx",
"src/mobile/screens/trip/sheets/MMehrSheet.tsx"
]
},
{
"feature": "Atlas country/region action sheet",
"hooks": ["src/pages/atlas/useAtlasCountryActions.ts"],
@@ -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).",
-2
View File
@@ -729,8 +729,6 @@
"src/components/Planner/parsedItemToDraft.ts",
"src/components/Planner/placeAmap.test.ts",
"src/components/Planner/placeAmap.ts",
"src/components/Planner/placeCategoryGuess.test.ts",
"src/components/Planner/placeCategoryGuess.ts",
"src/components/Planner/placeCoMaps.test.ts",
"src/components/Planner/placeCoMaps.ts",
"src/components/Planner/placeDialogParts.test.tsx",
+1 -1
View File
@@ -30,7 +30,7 @@
"planner.ts": 53,
"reservations.ts": 21,
"roadtrip.ts": 38,
"settings.ts": 40,
"settings.ts": 37,
"share.ts": 7,
"shared.ts": 2,
"storage.ts": 16,
+6 -6
View File
@@ -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,
-1
View File
@@ -148,7 +148,6 @@
"tests/integration/api/client.test.ts": 1,
"tests/integration/hooks/useRouteCalculation.test.ts": 1,
"tests/unit/i18n/i18nKeysScript.test.ts": 3,
"tests/unit/mobile/admin/MAdminAddonManager.test.tsx": 2,
"tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx": 1,
"tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx": 1,
"tests/unit/mobile/admin/MAdminStoragePanel.test.tsx": 1,
+1 -1
View File
@@ -156,7 +156,7 @@
"mobile/components/MToastHost.tsx": 2,
"mobile/components/MUserPickerList.tsx": 1,
"mobile/screens/admin/MAdmin.tsx": 9,
"mobile/screens/admin/MAdminAddonManager.tsx": 26,
"mobile/screens/admin/MAdminAddonManager.tsx": 13,
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
+8
View File
@@ -62,6 +62,8 @@ const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
// The desktop assistant window: its own chunk, and nothing at all until a connection is set up.
const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost'))
// The ten phone screens are chunks of their own, alongside the desktop pages
// rather than inside them. Each page used to import its M screen statically and
@@ -403,6 +405,12 @@ export default function App() {
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
{/* The phone has its own assistant screen inside the trip. */}
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
<Suspense fallback={null}><AssistantHost /></Suspense>
</ErrorBoundary>
)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
+133
View File
@@ -0,0 +1,133 @@
/**
* FE-AI-API-001 to FE-AI-API-007
*
* The two streaming calls bypass axios, so they carry by hand what the axios
* instance would add (the cookie, the socket id) and turn a refusal before the
* stream into a typed error the chat can name. The refusal's code comes from
* the body when the server names one, and from the status otherwise.
*/
import type { AssistantChatRequest } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { assistantApi, AssistantRequestError } from './assistant';
import { getSocketId } from './websocket';
const BODY: AssistantChatRequest = { locale: 'en', timeZone: 'Europe/Berlin', threadId: null, text: 'Hi' };
function streamResponse(text: string): Response {
const bytes = new TextEncoder().encode(text);
let sent = false;
return {
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => {
if (sent) return { done: true, value: undefined };
sent = true;
return { done: false, value: bytes };
},
cancel: async () => {},
}),
},
} as unknown as Response;
}
function refusal(status: number, body: unknown): Response {
return {
ok: false,
status,
body: null,
json: async () => {
if (body === undefined) throw new SyntaxError('not json');
return body;
},
} as unknown as Response;
}
async function refusedWith(res: Response) {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(res);
try {
await assistantApi.chat(4, BODY, new AbortController().signal);
} catch (err) {
return err;
}
throw new Error('expected a refusal');
}
afterEach(() => vi.restoreAllMocks());
describe('assistantApi streams', () => {
it('FE-AI-API-001: posts the message as JSON with the cookie and the socket id, and yields the chunks', async () => {
vi.mocked(getSocketId).mockReturnValueOnce('sock-1');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(streamResponse('data: {"type":"start","messageId":"m1"}\n\ndata: [DONE]\n\n'));
const signal = new AbortController().signal;
const chunks: unknown[] = [];
for await (const chunk of await assistantApi.chat(4, BODY, signal)) chunks.push(chunk);
expect(chunks).toEqual([{ type: 'start', messageId: 'm1' }]);
const [url, init] = fetchSpy.mock.calls[0] ?? [];
expect(url).toBe('/api/trips/4/assistant/chat');
expect(init).toMatchObject({ method: 'POST', credentials: 'include', signal, body: JSON.stringify(BODY) });
expect(init?.headers).toMatchObject({
'content-type': 'application/json',
accept: 'text/event-stream',
'X-Socket-Id': 'sock-1',
});
});
it('FE-AI-API-002: sends decisions to the thread they belong to', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamResponse('data: [DONE]\n\n'));
const body = { locale: 'de', timeZone: 'UTC', decisions: [{ toolCallId: 'c1', approved: true }] };
await assistantApi.decide(4, 7, body, new AbortController().signal);
expect(fetchSpy.mock.calls[0]?.[0]).toBe('/api/trips/4/assistant/threads/7/decisions');
});
it('FE-AI-API-003: a refusal names the code the body gives', async () => {
const err = await refusedWith(refusal(429, { error: 'Daily limit reached', code: 'daily_limit' }));
expect(err).toBeInstanceOf(AssistantRequestError);
expect(err).toMatchObject({ code: 'daily_limit', status: 429 });
});
it('FE-AI-API-004: without a known code the status decides', async () => {
expect(await refusedWith(refusal(429, { error: 'slow down' }))).toMatchObject({ code: 'too_many_requests' });
expect(await refusedWith(refusal(409, { error: 'x', code: 'conflict' }))).toMatchObject({ code: 'busy' });
expect(await refusedWith(refusal(502, undefined))).toMatchObject({ code: 'provider_unavailable' });
expect(await refusedWith(refusal(500, { error: 'boom' }))).toMatchObject({ code: 'generic' });
});
it('FE-AI-API-005: an accepted request without a body is a generic failure, not a silent empty answer', async () => {
const err = await refusedWith({ ok: true, status: 200, body: null } as unknown as Response);
expect(err).toMatchObject({ code: 'generic', status: 200 });
});
});
describe('assistantApi JSON calls', () => {
it('FE-AI-API-006: undo posts an empty JSON body to the escaped tool call id', async () => {
let seen: { path: string; body: unknown } | null = null;
server.use(
http.post('/api/trips/4/assistant/tool-calls/:id/undo', async ({ request, params }) => {
seen = { path: String(params.id), body: await request.json() };
return HttpResponse.json({ undone: true });
})
);
await expect(assistantApi.undo(4, 'a/b')).resolves.toEqual({ undone: true });
expect(seen).toEqual({ path: 'a/b', body: {} });
});
it('FE-AI-API-007: stop posts an empty JSON body', async () => {
let body: unknown = null;
server.use(
http.post('/api/trips/4/assistant/stop', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
})
);
await expect(assistantApi.stop(4)).resolves.toEqual({ success: true });
expect(body).toEqual({});
});
});
+189
View File
@@ -0,0 +1,189 @@
import {
ASSISTANT_ERROR_CODES,
type AiAdminConnectionResponse,
type AiAdminSettings,
type AiApiKeyConnectRequest,
type AiAuthStartResponse,
type AiChatgptFinishRequest,
type AiClaudeConnectorResponse,
type AiConnectionScope,
type AiConnectionView,
type AiInstanceConnectionRequest,
type AiModelListResponse,
type AiOpenrouterFinishRequest,
type AiPrivacyAckRequest,
type AiStatusResponse,
type AssistantAddFoundPlaceRequest,
type AssistantAddFoundPlaceResponse,
type AssistantChatRequest,
type AssistantDecisionsRequest,
type AssistantErrorCode,
type AssistantShareRequest,
type AssistantThreadListResponse,
type AssistantThreadResponse,
type AssistantUndoResponse,
type CollabMessageResponse,
type SuccessResponse,
type UiStreamChunk,
} from '@trek/shared';
import apiClient from './client';
import { parseUiMessageStream } from './uiMessageStream';
import { getSocketId } from './websocket';
/**
* The trip assistant and the AI connections behind it, in their own file
* rather than in the `client.ts` god module.
*
* The two streaming calls (a new message, and the decisions on held changes)
* go through `fetch`, not axios: the answer is a server-sent event stream that
* runs for as long as the model works, and axios would buffer it whole behind
* its 8 s timeout. Everything else is plain JSON through `apiClient`.
*/
/** A refusal the server answered before the stream opened, named by its `AssistantErrorCode`. */
export class AssistantRequestError extends Error {
readonly code: AssistantErrorCode;
readonly status: number;
constructor(code: AssistantErrorCode, status: number) {
super(`Assistant request refused: ${code}`);
this.name = 'AssistantRequestError';
this.code = code;
this.status = status;
}
}
const KNOWN_CODES: ReadonlySet<string> = new Set(ASSISTANT_ERROR_CODES);
/**
* The code of a refusal: the body's `code` when it names one, otherwise what
* the status alone can say.
*/
async function refusalCode(res: Response): Promise<AssistantErrorCode> {
try {
const body: unknown = await res.json();
const code = (body as { code?: unknown } | null)?.code;
if (typeof code === 'string' && KNOWN_CODES.has(code)) return code as AssistantErrorCode;
} catch {
// Not JSON (a proxy's error page); the status below still says something.
}
// A bare 429 is TREK's own limit or a proxy's; a provider's limit always names its code.
if (res.status === 429) return 'too_many_requests';
if (res.status === 409) return 'busy';
if (res.status === 502 || res.status === 503 || res.status === 504) return 'provider_unavailable';
return 'generic';
}
async function openStream(path: string, body: unknown, signal: AbortSignal): Promise<AsyncGenerator<UiStreamChunk>> {
// A file goes as multipart, where the browser sets the content type with its boundary.
const form = body instanceof FormData;
const headers: Record<string, string> = form
? { accept: 'text/event-stream' }
: { 'content-type': 'application/json', accept: 'text/event-stream' };
const socketId = getSocketId();
if (socketId) headers['X-Socket-Id'] = socketId;
const res = await fetch(`/api${path}`, {
method: 'POST',
credentials: 'include',
headers,
body: form ? body : JSON.stringify(body),
signal,
});
if (!res.ok) throw new AssistantRequestError(await refusalCode(res), res.status);
if (!res.body) throw new AssistantRequestError('generic', res.status);
return parseUiMessageStream(res.body);
}
const tripBase = (tripId: number) => `/trips/${tripId}/assistant`;
/**
* For the calls that make the server go and ask the provider (listing models,
* testing a key, exchanging a sign-in code): the shared 8 s is TREK's own
* budget and too short for a provider's.
*/
const viaProvider = { timeout: 30_000 };
export const assistantApi = {
threads: (tripId: number): Promise<AssistantThreadListResponse> =>
apiClient.get(`${tripBase(tripId)}/threads`).then((r) => r.data),
thread: (tripId: number, threadId: number): Promise<AssistantThreadResponse> =>
apiClient.get(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
deleteThread: (tripId: number, threadId: number): Promise<SuccessResponse> =>
apiClient.delete(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
undo: (tripId: number, toolCallId: string): Promise<AssistantUndoResponse> =>
apiClient.post(`${tripBase(tripId)}/tool-calls/${encodeURIComponent(toolCallId)}/undo`, {}).then((r) => r.data),
// Ends this user's running turn, wherever it runs (another tab included).
stop: (tripId: number): Promise<SuccessResponse> =>
apiClient.post(`${tripBase(tripId)}/stop`, {}).then((r) => r.data),
// A place a search found, suggested for a day: answers with the message holding the change.
addFoundPlace: (
tripId: number,
threadId: number,
body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
// An answer posted to the trip chat, marked as shared from the assistant; the client's socket id skips the echo.
share: (
tripId: number,
threadId: number,
messageId: string,
body: AssistantShareRequest
): Promise<CollabMessageResponse> =>
apiClient
.post(`${tripBase(tripId)}/threads/${threadId}/messages/${encodeURIComponent(messageId)}/share`, body)
.then((r) => r.data),
chat: (tripId: number, body: AssistantChatRequest, signal: AbortSignal) =>
openStream(`${tripBase(tripId)}/chat`, body, signal),
decide: (tripId: number, threadId: number, body: AssistantDecisionsRequest, signal: AbortSignal) =>
openStream(`${tripBase(tripId)}/threads/${threadId}/decisions`, body, signal),
// A booking file: the server reads it and proposes each booking it finds as a change.
attach: (tripId: number, form: FormData, signal: AbortSignal) =>
openStream(`${tripBase(tripId)}/attachments`, form, signal),
};
/** A user's own connection, and what the assistant runs on for them. */
export const aiApi = {
status: (): Promise<AiStatusResponse> => apiClient.get('/ai/status').then((r) => r.data),
models: (): Promise<AiModelListResponse> => apiClient.get('/ai/models', viaProvider).then((r) => r.data),
// Saving tests the key against the provider first.
connectKey: (body: AiApiKeyConnectRequest): Promise<AiConnectionView> =>
apiClient.put('/ai/connection', body, viaProvider).then((r) => r.data),
setModel: (model: string): Promise<AiConnectionView> =>
apiClient.patch('/ai/connection/model', { model }).then((r) => r.data),
disconnect: (): Promise<SuccessResponse> => apiClient.delete('/ai/connection').then((r) => r.data),
ackPrivacy: (body: AiPrivacyAckRequest): Promise<SuccessResponse> =>
apiClient.post('/ai/privacy-ack', body).then((r) => r.data),
chatgptStart: (scope: AiConnectionScope = 'user'): Promise<AiAuthStartResponse> =>
apiClient
.post(scope === 'instance' ? '/admin/ai/connect/chatgpt/start' : '/ai/connect/chatgpt/start', {})
.then((r) => r.data),
chatgptFinish: (body: AiChatgptFinishRequest): Promise<AiConnectionView> =>
apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data),
chatgptSignOut: (): Promise<SuccessResponse> =>
apiClient.post('/ai/connect/chatgpt/sign-out', {}).then((r) => r.data),
openrouterStart: (scope: AiConnectionScope): Promise<AiAuthStartResponse> =>
apiClient
.post(scope === 'instance' ? '/admin/ai/connect/openrouter/start' : '/ai/connect/openrouter/start', { scope })
.then((r) => r.data),
openrouterFinish: (body: AiOpenrouterFinishRequest): Promise<AiConnectionView> =>
apiClient.post('/ai/connect/openrouter/finish', body, viaProvider).then((r) => r.data),
};
/** The connection the instance provides for everyone, and the assistant's admin settings. */
export const aiAdminApi = {
settings: (): Promise<AiAdminSettings> => apiClient.get('/admin/ai/settings').then((r) => r.data),
saveSettings: (body: AiAdminSettings): Promise<AiAdminSettings> =>
apiClient.put('/admin/ai/settings', body).then((r) => r.data),
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
};
export const claudeConnectorApi = {
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
};
+27
View File
@@ -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']);
});
});
+10
View File
@@ -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),
};
+85
View File
@@ -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 }]);
});
});
+44
View File
@@ -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),
};
+8
View File
@@ -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)),
};
+3 -1
View File
@@ -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');
+3 -2
View File
@@ -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> =>
+150
View File
@@ -0,0 +1,150 @@
/**
* FE-AI-STREAM-001 to FE-AI-STREAM-011
*
* The parser reads the server's assistant stream, so its contract is the
* shared fixture: the same file the server's writer is tested against. What
* the network does to that stream (cutting it at any byte, CRLF from a proxy,
* keep-alive comments) must not change what comes out.
*/
import { uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { MAX_EVENT_CHARS, parseUiMessageStream, STALL_MS } from './uiMessageStream';
const FIXTURE = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), '../../../shared/src/assistant/__fixtures__/ui-stream.v1.txt'),
'utf8'
);
/** A response body that hands out the given pieces one read at a time. */
function bodyOf(pieces: (string | Uint8Array)[]) {
const encoder = new TextEncoder();
let i = 0;
const cancel = vi.fn(async () => {});
const body = {
getReader: () => ({
read: async () => {
if (i >= pieces.length) return { done: true, value: undefined };
const piece = pieces[i++];
return { done: false, value: typeof piece === 'string' ? encoder.encode(piece) : piece };
},
cancel,
}),
} as unknown as ReadableStream<Uint8Array>;
return { body, cancel };
}
async function collect(body: ReadableStream<Uint8Array>): Promise<UiStreamChunk[]> {
const chunks: UiStreamChunk[] = [];
for await (const chunk of parseUiMessageStream(body)) chunks.push(chunk);
return chunks;
}
/** The fixture's chunks as plain JSON, the way a reader of the file sees them. */
function fixtureChunks(): UiStreamChunk[] {
return FIXTURE.split('\n\n')
.map((event) => event.replace(/^data: /, '').trim())
.filter((data) => data && data !== '[DONE]')
.map((data) => uiStreamChunkSchema.parse(JSON.parse(data)));
}
describe('parseUiMessageStream', () => {
it('FE-AI-STREAM-001: reads the shared fixture into its chunks, in order', async () => {
const chunks = await collect(bodyOf([FIXTURE]).body);
expect(chunks).toEqual(fixtureChunks());
expect(chunks.map((c) => c.type)).toContain('tool-approval-request');
expect(chunks[chunks.length - 1]).toEqual({ type: 'finish', finishReason: 'tool-calls' });
});
it('FE-AI-STREAM-002: gives the same chunks when every byte arrives in its own read', async () => {
const bytes = new TextEncoder().encode(FIXTURE);
const pieces = Array.from(bytes, (b) => new Uint8Array([b]));
expect(await collect(bodyOf(pieces).body)).toEqual(fixtureChunks());
});
it('FE-AI-STREAM-003: reads CRLF line ends, also when a CR and its LF arrive apart', async () => {
const crlf = FIXTURE.replace(/\n/g, '\r\n');
const cut = crlf.indexOf('\r\n') + 1;
expect(await collect(bodyOf([crlf.slice(0, cut), crlf.slice(cut)]).body)).toEqual(fixtureChunks());
});
it('FE-AI-STREAM-004: skips keep-alive comments and fields other than data', async () => {
const stream = [
': keepalive\n\n',
'event: message\nid: 1\ndata: {"type":"start-step"}\n\n',
': keepalive\n\n',
'data: [DONE]\n\n',
].join('');
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'start-step' }]);
});
it('FE-AI-STREAM-005: skips chunk types this client does not know, and torn JSON', async () => {
const stream = [
'data: {"type":"reasoning-delta","delta":"hm"}\n\n',
'data: {"type":"text-delta","id":"t"\n\n',
'data: {"type":"text-delta","id":"t","delta":"ok"}\n\n',
'data: [DONE]\n\n',
].join('');
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'ok' }]);
});
it('FE-AI-STREAM-006: reads an unknown error code as generic', async () => {
const stream = 'data: {"type":"error","errorText":"quota_exploded"}\n\ndata: [DONE]\n\n';
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'error', errorText: 'generic' }]);
});
it('FE-AI-STREAM-007: joins the lines of a multi-line data field', async () => {
const stream = 'data: {"type":"text-delta",\ndata: "id":"t","delta":"x"}\n\ndata: [DONE]\n\n';
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'x' }]);
});
it('FE-AI-STREAM-008: stops at [DONE] and lets go of the connection', async () => {
const { body, cancel } = bodyOf([
'data: {"type":"start-step"}\n\ndata: [DONE]\n\ndata: {"type":"finish-step"}\n\n',
]);
expect(await collect(body)).toEqual([{ type: 'start-step' }]);
expect(cancel).not.toHaveBeenCalled();
});
it('FE-AI-STREAM-009: a stream that closes before [DONE] broke off, after the chunks it finished', async () => {
const { body, cancel } = bodyOf(['data: {"type":"start-step"}\n\ndata: {"type":"finish-st']);
const seen: UiStreamChunk[] = [];
const read = async () => {
for await (const chunk of parseUiMessageStream(body)) seen.push(chunk);
};
await expect(read()).rejects.toThrow('broke off');
expect(seen).toEqual([{ type: 'start-step' }]);
expect(cancel).not.toHaveBeenCalled();
});
it('FE-AI-STREAM-011: a stream that stays silent past the stall limit fails and lets go', async () => {
vi.useFakeTimers();
try {
const cancel = vi.fn(async () => {});
const body = {
getReader: () => ({ read: () => new Promise(() => {}), cancel }),
} as unknown as ReadableStream<Uint8Array>;
const failed = expect(collect(body)).rejects.toThrow('stalled');
await vi.advanceTimersByTimeAsync(STALL_MS);
await failed;
expect(cancel).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('FE-AI-STREAM-010: refuses an event past the size limit, and a consumer leaving early cancels the read', async () => {
const huge = bodyOf([`data: ${'x'.repeat(MAX_EVENT_CHARS + 1)}`]);
await expect(collect(huge.body)).rejects.toThrow('too large');
expect(huge.cancel).toHaveBeenCalledTimes(1);
const early = bodyOf([FIXTURE]);
for await (const chunk of parseUiMessageStream(early.body)) {
expect(chunk.type).toBe('start');
break;
}
expect(early.cancel).toHaveBeenCalledTimes(1);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { UI_STREAM_DONE, uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared';
/**
* Reads the assistant's server-sent events into typed chunks.
*
* The wire format is the `@trek/shared` stream contract: one `data: <json>`
* event per chunk, closed by `data: [DONE]`. Our own parser rather than the
* `assistant-stream` one, because that one drops `tool-approval-request`, the
* chunk a held change hangs on, and it would pull the chat runtime into every
* chunk that only needs the data layer.
*
* What it tolerates, because proxies and the network do these things: events
* split across reads at any byte, CRLF line ends, `:` keep-alive comments,
* and fields other than `data`. A chunk that does not match the contract,
* a type this client does not know among them, is skipped, so the server can
* add chunk types without breaking an older tab. An event longer than
* MAX_EVENT_CHARS ends the stream with an error instead of growing the buffer
* without bound.
*
* The server always closes with `[DONE]` and sends a keep-alive every 15
* seconds, so a stream that ends without the marker, or stays silent for
* STALL_MS, lost its server (a restart, a dropped proxy) and fails instead of
* leaving the chat waiting for an answer that will not come.
*/
export const MAX_EVENT_CHARS = 1024 * 1024;
export const STALL_MS = 45_000;
/** The next read, or a failure once nothing came for STALL_MS. */
async function readOrStall(reader: ReadableStreamDefaultReader<Uint8Array>) {
let timer: ReturnType<typeof setTimeout> | undefined;
const stalled = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('Assistant stream stalled')), STALL_MS);
});
try {
return await Promise.race([reader.read(), stalled]);
} finally {
clearTimeout(timer);
}
}
export async function* parseUiMessageStream(body: ReadableStream<Uint8Array>): AsyncGenerator<UiStreamChunk> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finished = false;
try {
for (;;) {
const { done, value } = await readOrStall(reader);
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
// A CR at the very end may be the first half of a CRLF; it waits for the next read.
const carry = !done && buffer.endsWith('\r') ? '\r' : '';
if (carry) buffer = buffer.slice(0, -1);
buffer = buffer.replace(/\r\n?/g, '\n');
let boundary = buffer.indexOf('\n\n');
while (boundary >= 0) {
const data = dataOf(buffer.slice(0, boundary));
buffer = buffer.slice(boundary + 2);
if (data === UI_STREAM_DONE) {
finished = true;
return;
}
const chunk = data === null ? null : chunkOf(data);
if (chunk) yield chunk;
boundary = buffer.indexOf('\n\n');
}
buffer += carry;
if (buffer.length > MAX_EVENT_CHARS) throw new Error('Assistant stream event too large');
// The connection closed before [DONE]: the server went away mid-answer.
if (done) {
finished = true;
throw new Error('Assistant stream broke off');
}
}
} finally {
// Leaving early (the consumer stopped, or an error) releases the connection.
if (!finished) {
try {
await reader.cancel();
} catch {
// The stream already failed; the error that brought us here is the one that counts.
}
}
}
}
/** The joined `data` lines of one event, or null for an event without any (a comment). */
function dataOf(event: string): string | null {
const lines: string[] = [];
for (const line of event.split('\n')) {
if (line === 'data' || line.startsWith('data:')) {
const value = line.slice(5);
lines.push(value.startsWith(' ') ? value.slice(1) : value);
}
}
return lines.length ? lines.join('\n') : null;
}
function chunkOf(data: string): UiStreamChunk | null {
let json: unknown;
try {
json = JSON.parse(data);
} catch {
// A torn or foreign event; the next one is still worth reading.
return null;
}
const parsed = uiStreamChunkSchema.safeParse(json);
return parsed.success ? parsed.data : null;
}
+3
View File
@@ -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)
+54 -279
View File
@@ -1,6 +1,6 @@
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
@@ -24,23 +24,20 @@ function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
}
function llmAddon(config: Record<string, unknown> = {}) {
function llmAddon(config: Record<string, unknown> = {}, enabled = true) {
return buildAddon({
id: 'llm_parsing',
name: 'AI Parsing',
description: 'Extract bookings from files',
icon: 'Sparkles',
type: 'integration',
enabled: true,
enabled,
config,
});
}
function modelsRoute(names: string[], seen?: (string | null)[]) {
return http.get('/api/admin/llm/local/models', ({ request }) => {
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
});
function aiAddon(id: string, name: string, enabled: boolean) {
return buildAddon({ id, name, icon: id === 'mcp' ? 'Terminal' : 'Sparkles', type: 'integration', enabled });
}
/** Every switch is labelled with the thing it controls, top-level tile or shelf
@@ -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));
+34 -174
View File
@@ -1,9 +1,7 @@
import { useId, type ComponentType } from 'react'
import type { ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
@@ -12,11 +10,10 @@ import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel'
import { useAddonManager } from './addons/useAddonManager'
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
import AiAddonTile from './addons/AiAddonTile'
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
import WandererMark from '../Tours/wanderer/WandererMark'
@@ -74,7 +71,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const { t } = useTranslation()
const dark = useIsDark()
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
mapSwitches,
@@ -154,33 +151,29 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
})
}
// An array, not a fragment: the tile counts its rows to fold them behind the
// "N options" bar the way Collab's and Documents' are.
if (addon.id === 'tours' && addon.enabled) {
return (
<>
return [
<AddonSubRow
key="wanderer"
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>,
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
key={key}
icon={<Icon size={14} />}
title={t(titleKey)}
description={t(subtitleKey)}
enabled={enabled}
onToggle={() => void toggle()}
/>
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
key={key}
icon={<Icon size={14} />}
title={t(titleKey)}
description={t(subtitleKey)}
enabled={enabled}
onToggle={() => void toggle()}
/>
))}
</>
)
}
// The AI-parsing settings hang off their tile like every other sub-feature,
// just as a form instead of toggle rows.
if (addon.id === 'llm_parsing' && addon.enabled) {
return <LlmParsingConfig addon={addon} />
)),
]
}
return null
}
@@ -209,9 +202,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{ key: 'trip', addons: tripAddons },
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter(g => g.addons.length > 0)
].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0))
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + aiAddons.length
const subtitle = (
<span className="inline-flex flex-wrap items-center gap-1">
@@ -242,8 +235,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const meta = TYPE_META[g.key]
return (
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
<div className="divide-y divide-edge-faint">
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{g.addons.map(tile)}
</div>
</section>
)
})}
@@ -282,140 +279,3 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
</div>
)
}
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
* overrides per-user config (see server llmConfig.ts). The API key is masked on
* read; an unchanged mask is treated as a no-op by the server. For the local
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const { t } = useTranslation()
const {
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
} = useLlmParsingConfig(addon)
const fieldId = useId()
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
/* Lives in the tile's shelf like the collab toggles, so it is one column of
eyebrow fields: the band this used to be had a whole page width. */
return (
<li className={`flex flex-col gap-3 p-3 ${saving ? 'opacity-60' : ''}`}>
<SettingsHint>
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
</SettingsHint>
<EditorField label="Provider">
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</EditorField>
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
</EditorField>
<EditorField
label="API key"
htmlFor={`${fieldId}-key`}
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
>
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</EditorField>
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</EditorField>
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
<Segmented<LlmVision>
label={t('settings.aiParsing.multimodal')}
value={vision}
onChange={v => setVision(asLlmVision(v))}
options={visionOptions}
fill
/>
</EditorField>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
<div className="flex items-center gap-2">
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
<button type="button" onClick={loadModels} disabled={loadingModels}
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
style={fs(11.5, 'body')}>
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button type="button"
key={name}
title={name}
onClick={() => setModel(name)}
aria-pressed={model === name}
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
style={fs(11.5)}
>
{name}
</button>
))}
</div>
)}
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className="min-w-0" title={m.note}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
{installedHere ? (
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
{isPulling && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
)
})}
</div>
</div>
)}
<div className="flex justify-end">
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</li>
)
}
@@ -0,0 +1,95 @@
import { ChevronRight, Settings2, Sparkles } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { StatusPill } from '../../Settings/settingsKit';
import { fs } from '../../shared/DialogShell';
import AddonSubRow from '../AddonSubRow';
import { type Addon, getAddonLabel } from './addonModel';
import { AI_ADDON_ICONS, type AiAddonId } from './aiAddons';
import AiSettingsDialog from './AiAdminConfig';
import { useAiAddonTile } from './useAiAddonTile';
/**
* The AI addons as one tile at the head of the integration column: the
* assistant, AI parsing and MCP as switches in its shelf, and one button to
* the dialog that holds their settings, so a page with all three on stays as
* short as one with none.
*/
export default function AiAddonTile({
addons,
onToggle,
}: {
addons: Addon[];
onToggle: (addon: Addon) => void;
}): React.ReactElement {
const { t } = useTranslation();
const tile = useAiAddonTile(addons);
const anyOn = tile.enabledCount > 0;
return (
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
<div className="flex items-start gap-3">
<div
className={`grid h-9 w-9 flex-none place-items-center rounded-[10px] transition-colors ${
anyOn
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
: 'bg-surface-tertiary text-content-faint'
}`}
>
<Sparkles size={18} />
</div>
<div className="min-w-0 flex-1 pt-px">
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
{t('admin.addons.ai.name')}
</h4>
<p
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${anyOn ? 'text-content-muted' : 'text-content-faint'}`}
style={fs(11.5)}
>
{t('admin.addons.ai.description')}
</p>
</div>
<div className="flex-none pt-1">
<StatusPill tone={anyOn ? 'success' : 'neutral'}>
{tile.enabledCount}/{addons.length}
</StatusPill>
</div>
</div>
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
{addons.map((addon) => {
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
const label = getAddonLabel(t, addon);
return (
<AddonSubRow
key={addon.id}
icon={<Icon size={14} />}
title={label.name}
description={label.description}
enabled={addon.enabled}
onToggle={() => onToggle(addon)}
/>
);
})}
</ul>
{tile.canSetUp && (
<button
type="button"
onClick={tile.openDialog}
className="flex min-h-[44px] w-full items-center gap-2.5 border-t border-edge-faint px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
>
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
<Settings2 size={14} />
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.addons.ai.setup')}
</span>
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
</button>
)}
</div>
{tile.dialogOpen && <AiSettingsDialog addons={addons} onClose={tile.closeDialog} />}
</article>
);
}
@@ -0,0 +1,339 @@
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-020: the AI settings dialog behind the AI tile.
import userEvent from '@testing-library/user-event';
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
import { delay, http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
import { useAssistantStore } from '../../../store/assistantStore';
import { ToastContainer } from '../../shared/Toast';
import type { Addon } from './addonModel';
const repo = vi.hoisted(() => ({
status: vi.fn(),
adminSettings: vi.fn(),
saveAdminSettings: vi.fn(),
adminConnection: vi.fn(),
saveAdminConnection: vi.fn(),
removeAdminConnection: vi.fn(),
adminModels: vi.fn(),
}));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
const LIVE: AiAdminConnectionResponse = {
connection: {
scope: 'instance',
kind: 'openai',
model: 'gpt-5.5',
hint: '…9f3c',
status: 'ok',
host: 'api.openai.com',
baseUrl: null,
},
prefill: null,
};
function addon(id: string, enabled: boolean, config?: Record<string, unknown>): Addon {
return { id, name: id, description: '', icon: 'Sparkles', type: 'integration', enabled, config } as Addon;
}
const ASSISTANT = addon('ai_assistant', true);
const parsing = (config: Record<string, unknown> = {}) => addon('llm_parsing', true, config);
function renderDialog(addons: Addon[] = [ASSISTANT]) {
const onClose = vi.fn();
render(
<>
<ToastContainer />
<AiSettingsDialog addons={addons} onClose={onClose} />
</>
);
return { onClose };
}
/** The dialog opened on AI parsing's tab. */
async function renderParsing(config: Record<string, unknown> = {}) {
const user = userEvent.setup();
renderDialog([ASSISTANT, parsing(config)]);
await user.click(await screen.findByRole('button', { name: 'AI Parsing' }));
return user;
}
function modelsRoute(names: string[], seen?: (string | null)[]) {
return http.get('/api/admin/llm/local/models', ({ request }) => {
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
});
}
function saveRoute(bodies: unknown[], fail = false) {
return http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return fail ? HttpResponse.error() : HttpResponse.json({ success: true });
});
}
beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
repo.status.mockRejectedValue(new Error('not needed'));
repo.adminSettings.mockResolvedValue(SETTINGS);
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
repo.adminConnection.mockResolvedValue({ connection: null, prefill: null });
repo.removeAdminConnection.mockResolvedValue(undefined);
repo.adminModels.mockResolvedValue([{ id: 'gpt-5.5', label: 'GPT-5.5', supportsTools: true }]);
});
describe('the connection and the assistant', () => {
it('FE-COMP-AI-ADMIN-001: shows who pays and the setup for everyone, the limits on the assistant tab', async () => {
const user = userEvent.setup();
renderDialog();
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
// The admin may share their own ChatGPT plan with everyone, so its tile is offered too.
expect(screen.getByRole('button', { name: /Sign in with ChatGPT/ })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
expect(screen.getByText('Keep chats')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
const user = userEvent.setup();
renderDialog();
await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' }));
await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument());
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => {
const user = userEvent.setup();
repo.adminConnection.mockResolvedValue(LIVE);
renderDialog();
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('…9f3c')).toBeInTheDocument();
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Change' }));
expect(screen.getByText('What would you like to chat with?')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.getByText('OpenAI')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-004: removing the connection asks first', async () => {
const user = userEvent.setup();
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue({ connection: null, prefill: null });
renderDialog();
await user.click(await screen.findByRole('button', { name: 'Remove connection' }));
expect(screen.getByText('Disconnect OpenAI? Your chats stay.')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Remove connection' });
await user.click(buttons[buttons.length - 1]);
expect(await screen.findByText('No connection yet.')).toBeInTheDocument();
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
});
it('FE-COMP-AI-ADMIN-005: a tab shows only while its addon is on, and Close closes', async () => {
const user = userEvent.setup();
const { onClose } = renderDialog([addon('ai_assistant', false), parsing()]);
expect(await screen.findByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'AI Assistant' })).not.toBeInTheDocument();
const closes = screen.getAllByRole('button', { name: 'Close' });
await user.click(closes[closes.length - 1]);
expect(onClose).toHaveBeenCalled();
});
});
describe('AI parsing', () => {
it('FE-COMP-AI-ADMIN-010: without its own connection parsing reads with the shared one, and can be given its own', async () => {
repo.adminConnection.mockResolvedValue(LIVE);
server.use(modelsRoute([]));
const user = await renderParsing({ vision: 'auto' });
expect(await screen.findByText('Shared')).toBeInTheDocument();
expect(screen.getByText('gpt-5.5')).toBeInTheDocument();
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Use a different one' }));
expect(screen.getByLabelText('Model')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-011: with no connection anywhere it says so and leads to the connection tab', async () => {
const user = await renderParsing();
expect(
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
).toBeInTheDocument();
// The link in the hint, not the tab of the same name above it.
const link = screen.getAllByRole('button', { name: 'Connection' }).find((b) => !b.hasAttribute('aria-pressed'));
await user.click(link as HTMLElement);
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-012: an own connection is shown as such and goes back to the shared one, keeping the vision setting', async () => {
const bodies: unknown[] = [];
repo.adminConnection.mockResolvedValue(LIVE);
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'anthropic', model: 'claude-x', apiKey: '••••••••', vision: 'on' });
expect(await screen.findByText('Own connection', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByText('claude-x')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Use the shared connection' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({ config: { vision: 'on' } });
expect(await screen.findByText('Shared')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-013: the local provider lists installed models and a chip fills the model field', async () => {
const urls: (string | null)[] = [];
server.use(modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
const user = await renderParsing({ provider: 'local', model: 'old' });
await screen.findByText('Installed on the server');
await user.click(await screen.findByRole('button', { name: 'llama3:8b' }));
expect(urls[0]).toBe('http://localhost:11434/v1');
expect(screen.getByLabelText('Model')).toHaveValue('llama3:8b');
// qwen3.5:4b is installed already, so the recommended row offers Use instead of Pull.
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
it("FE-COMP-AI-ADMIN-014: an unreachable Ollama shows the server's reason and Refresh retries", async () => {
let calls = 0;
server.use(
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
})
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('down');
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet. Pull one below.');
expect(calls).toBe(2);
});
it('FE-COMP-AI-ADMIN-015: switching providers swaps the address placeholder and the Ollama block, and drops a stale address', async () => {
const bodies: unknown[] = [];
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'local', model: 'm', baseUrl: 'http://ollama.lan:11434/v1' });
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toHaveValue('');
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: 'Anthropic' }));
// A gateway can stand in for Anthropic too, so the field stays (#2655).
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
await user.clear(screen.getByLabelText('Model'));
await user.type(screen.getByLabelText('Model'), ' claude-haiku ');
await user.type(screen.getByLabelText('API key'), 'sk-ant-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('AI settings saved');
// The stale local address must not ride along to Anthropic, and the model is trimmed.
expect(bodies[0]).toEqual({
config: { provider: 'anthropic', model: 'claude-haiku', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
});
});
it('FE-COMP-AI-ADMIN-016: a hand-typed address is asked on blur, a cloud provider asks nothing', async () => {
const urls: (string | null)[] = [];
server.use(modelsRoute([], urls));
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('Installed on the server');
await user.type(screen.getByLabelText('Base URL'), 'http://ollama.lan:11434/v1');
await user.tab();
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
const before = urls.length;
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
await user.type(screen.getByLabelText('Base URL'), 'https://proxy.local/v1');
await user.tab();
expect(urls).toHaveLength(before);
});
it('FE-COMP-AI-ADMIN-017: a pull streams progress and then selects the model', async () => {
let pulled: unknown = null;
let modelCalls = 0;
server.use(
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
await delay(150);
return new HttpResponse(
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } }
);
})
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('No models installed yet. Pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
expect(await screen.findAllByText('Pulling…')).not.toHaveLength(0);
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
it('FE-COMP-AI-ADMIN-018: an error frame or a refused pull is reported and the button comes back', async () => {
server.use(
modelsRoute([]),
http.post(
'/api/admin/llm/local/pull',
() =>
new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', {
headers: { 'Content-Type': 'application/x-ndjson' },
})
)
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('No models installed yet. Pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('manifest not found');
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
});
it('FE-COMP-AI-ADMIN-019: saving keeps a masked key and reports both outcomes', async () => {
const bodies: unknown[] = [];
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••' });
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
});
server.use(saveRoute(bodies, true));
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Could not save AI settings');
});
it('FE-COMP-AI-ADMIN-020: whether the model reads images saves at once, with the connection as saved', async () => {
const bodies: unknown[] = [];
server.use(saveRoute(bodies));
const user = await renderParsing({
provider: 'openai',
model: 'gpt-4.1-mini',
baseUrl: '',
apiKey: 'sk',
vision: 'off',
});
expect(await screen.findByRole('button', { name: 'No' })).toBeInTheDocument();
expect(
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Yes' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({
config: { provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'on' },
});
});
});
@@ -0,0 +1,270 @@
import { Check, Loader2, Sparkles } from 'lucide-react';
import React, { useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import ConfirmDialog from '../../shared/ConfirmDialog';
import CustomSelect from '../../shared/CustomSelect';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
import { NumericInput } from '../../shared/NumericInput';
import { EditorField, INPUT, Segmented } from '../../shared/dialogParts';
import ParsingSettings from './ParsingSettings';
import type { Addon } from './addonModel';
import { POLICY_CHOICES, useAiAdminConfig } from './useAiAdminConfig';
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint';
/** Who pays, as three rows to pick one from: the labels are too long for a segmented track in a shelf. */
function PolicyChoice({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<span className={EYEBROW} style={fs(9.5)}>
{t('assistant.admin.policy')}
</span>
<div role="radiogroup" aria-label={t('assistant.admin.policy')} className="flex flex-col gap-1.5">
{POLICY_CHOICES.map((choice) => {
const on = A.policy === choice.value;
return (
<button
key={choice.value}
type="button"
role="radio"
aria-checked={on}
onClick={() => A.setPolicy(choice.value)}
className={`flex items-center gap-2 rounded-[10px] border px-3 py-2 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
>
<span
className={`min-w-0 flex-1 font-medium ${on ? 'text-content' : 'text-content-muted'}`}
style={fs(12.5, 'body')}
>
{t(choice.labelKey)}
</span>
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-content" />}
</button>
);
})}
</div>
{A.policyHintKey && <SettingsHint>{t(A.policyHintKey)}</SettingsHint>}
</div>
);
}
/** The connection everyone runs on, once set up: who answers, its model, and replacing or removing it. */
function LiveConnection({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const { live, provider, models, busy, setModel, replace, askRemove } = A.instance;
if (!live) return null;
const status = CONNECTION_STATUS[live.status];
return (
<div
className={`flex flex-col gap-3 rounded-[12px] border border-edge-faint bg-surface-card p-3 ${busy ? 'opacity-60' : ''}`}
>
<div className="flex items-center gap-2.5">
<ProviderMark kind={live.kind} />
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
{provider}
</p>
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
{live.hint ?? live.host}
</p>
</div>
<StatusPill tone={status.tone}>{t(status.labelKey)}</StatusPill>
</div>
{live.status === 'ok' && (
<EditorField label={t('assistant.connect.model')} hint={t('assistant.connect.modelHint')}>
<CustomSelect
ariaLabel={t('assistant.connect.model')}
value={live.model}
onChange={(v) => setModel(String(v))}
options={models}
size="sm"
disabled={busy}
searchable={models.length > 8}
/>
</EditorField>
)}
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
<button type="button" onClick={replace} disabled={busy} className={SETTINGS_BUTTON}>
{t('common.change')}
</button>
<button type="button" onClick={askRemove} disabled={busy} className={SETTINGS_BUTTON_DANGER}>
{t('assistant.admin.connectionRemove')}
</button>
</div>
</div>
);
}
function InstanceConnection({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const { live, connection, replacing, cancelReplace, onConnected } = A.instance;
return (
<div className="flex flex-col gap-2.5">
<span className={EYEBROW} style={fs(9.5)}>
{t('assistant.admin.connection')}
</span>
{!replacing && <LiveConnection A={A} />}
{!live && <SettingsHint>{t('assistant.admin.connectionNone')}</SettingsHint>}
{A.showPanel && (
<AiConnectPanel scope="instance" onConnected={onConnected} prefill={connection?.prefill ?? null} />
)}
{replacing && (
<button
type="button"
onClick={cancelReplace}
className={`${SETTINGS_BUTTON} self-start`}
style={fs(12.5, 'body')}
>
{t('common.cancel')}
</button>
)}
</div>
);
}
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
function AssistantLimits({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const fieldId = useId();
return (
<div className="flex flex-col gap-4">
{A.instancePays && (
<EditorField
label={t('assistant.admin.dailyLimit')}
htmlFor={`${fieldId}-daily`}
hint={t('assistant.admin.dailyLimitHint')}
>
<NumericInput
id={`${fieldId}-daily`}
mode="integer"
className={`${INPUT} tabular-nums`}
value={A.dailyDraft}
onValueChange={A.setDailyDraft}
onBlur={A.commitDailyLimit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
</EditorField>
)}
<EditorField label={t('assistant.admin.retention')}>
<CustomSelect
ariaLabel={t('assistant.admin.retention')}
value={A.retention}
onChange={(v) => A.setRetention(String(v))}
options={A.retentionOptions}
size="sm"
/>
</EditorField>
</div>
);
}
type AiTab = 'connection' | 'assistant' | 'parsing';
/**
* The AI settings behind the AI tile's button, in one dialog so the addon page
* stays short: the connection the assistant and AI parsing share (and who pays
* for it), the assistant's limits, and how bookings are read. A tab shows only
* while its addon is on. Each change saves at once.
*/
export default function AiSettingsDialog({
addons,
onClose,
}: {
addons: Addon[];
onClose: () => void;
}): React.ReactElement {
const { t } = useTranslation();
const labelId = useId();
const A = useAiAdminConfig();
const assistantOn = addons.some((a) => a.id === 'ai_assistant' && a.enabled);
const parsing = addons.find((a) => a.id === 'llm_parsing' && a.enabled) ?? null;
const tabs: { value: AiTab; label: string }[] = [
{ value: 'connection', label: t('admin.addons.ai.tab.connection') },
...(assistantOn ? [{ value: 'assistant' as const, label: t('admin.addons.catalog.ai_assistant.name') }] : []),
...(parsing ? [{ value: 'parsing' as const, label: t('admin.addons.catalog.llm_parsing.name') }] : []),
];
const [tab, setTab] = useState<AiTab>('connection');
const shown = tabs.some((x) => x.value === tab) ? tab : 'connection';
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="detail"
align="top"
blocked={A.instance.confirmingRemove}
header={
<DialogHeader
tile={
<DialogTile>
<Sparkles size={20} strokeWidth={2} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
eyebrow={t('admin.addons.ai.name')}
title={t('admin.addons.ai.setup')}
sub={t('admin.addons.ai.dialogSub')}
subWraps
onClose={onClose}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={onClose}>
{t('common.close')}
</DialogButton>
</DialogFooter>
}
>
{tabs.length > 1 && (
<Segmented<AiTab> label={t('admin.addons.ai.setup')} value={shown} onChange={setTab} options={tabs} fill />
)}
{!A.loaded ? (
<div className="grid place-items-center py-10">
<Loader2 size={20} className="animate-spin text-content-faint" />
</div>
) : (
<>
{shown === 'connection' && (
<div className="flex flex-col gap-4">
<PolicyChoice A={A} />
{A.instancePays && <InstanceConnection A={A} />}
</div>
)}
{shown === 'assistant' && <AssistantLimits A={A} />}
{shown === 'parsing' && parsing && (
<ParsingSettings addon={parsing} A={A} onOpenConnection={() => setTab('connection')} />
)}
</>
)}
<ConfirmDialog
isOpen={A.instance.confirmingRemove}
onClose={A.instance.cancelRemove}
onConfirm={A.instance.remove}
title={t('assistant.admin.connectionRemove')}
message={t('assistant.connect.disconnectConfirm', { provider: A.instance.provider })}
confirmLabel={t('assistant.admin.connectionRemove')}
danger
/>
</DialogShell>
);
}
@@ -0,0 +1,348 @@
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared';
import { Check, RefreshCw } from 'lucide-react';
import React, { useId } from 'react';
import { useTranslation } from '../../../i18n';
import { ProviderMark } from '../../Settings/ai/AiConnectPanel';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import { DialogSection, fs } from '../../shared/DialogShell';
import { EditorField, INPUT, PANEL, Segmented } from '../../shared/dialogParts';
import { type Addon, ANTHROPIC_HINT, MASKED, RECOMMENDED_MODELS } from './addonModel';
import type { useAiAdminConfig } from './useAiAdminConfig';
import { useLlmParsingConfig } from './useLlmParsingConfig';
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
type Parsing = ReturnType<typeof useLlmParsingConfig>;
type MarkKind = Parameters<typeof ProviderMark>[0]['kind'];
/** Parsing's own providers, with the mark the assistant shows for each. */
const OWN_PROVIDERS = [
{ value: 'local', labelKey: 'settings.aiParsing.providerLocal', mark: 'custom' },
{ value: 'openai', labelKey: 'settings.aiParsing.providerOpenai', mark: 'openai' },
{ value: 'anthropic', labelKey: 'settings.aiParsing.providerAnthropic', mark: 'anthropic' },
] as const;
const markOf = (provider: string): MarkKind => OWN_PROVIDERS.find((p) => p.value === provider)?.mark ?? 'custom';
/** The connection parsing reads with, as one line: a mark, who answers, the model and where it comes from. */
function ReadsWith({ mark, title, model, tag }: { mark: MarkKind; title: string; model: string; tag: string }) {
return (
<div className="flex items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
<ProviderMark kind={mark} />
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
{title}
</p>
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
{model}
</p>
</div>
<StatusPill>{tag}</StatusPill>
</div>
);
}
/** Which connection parsing reads with now, and the way to the other one. */
function ParsingSource({ A, P, onOpenConnection }: { A: AiAdmin; P: Parsing; onOpenConnection: () => void }) {
const { t } = useTranslation();
const live = A.instancePays ? A.instance.live : null;
if (P.ownConnection) {
const own = OWN_PROVIDERS.find((p) => p.value === P.savedProvider);
return (
<div className="flex flex-col gap-2">
<ReadsWith
mark={markOf(P.savedProvider)}
title={own ? t(own.labelKey) : P.savedProvider}
model={P.savedModel}
tag={t('admin.addons.ai.parsing.own')}
/>
<SettingsHint>{t('admin.addons.ai.parsing.ownHint')}</SettingsHint>
<button
type="button"
onClick={() => void P.switchToShared()}
disabled={P.saving}
className={`${SETTINGS_BUTTON} self-start`}
style={fs(12.5, 'body')}
>
{t('admin.addons.ai.parsing.useShared')}
</button>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{live ? (
<ReadsWith
mark={live.kind}
title={A.instance.provider}
model={live.model}
tag={t('admin.addons.ai.parsing.sharedTag')}
/>
) : (
<SettingsHint>
{t(A.policy === 'user' ? 'admin.addons.ai.parsing.perUser' : 'admin.addons.ai.parsing.none')}
</SettingsHint>
)}
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
{!live && A.policy !== 'user' && (
<button type="button" onClick={onOpenConnection} className={SETTINGS_BUTTON_PRIMARY}>
{t('admin.addons.ai.tab.connection')}
</button>
)}
{!P.formOpen && (
<button type="button" onClick={P.startOwn} className={SETTINGS_BUTTON}>
{t('admin.addons.ai.parsing.setupOwn')}
</button>
)}
</div>
</div>
);
}
/** The models on the local server to pick from, and the recommended ones to pull. */
function LocalModels({ P }: { P: Parsing }) {
const { t } = useTranslation();
return (
<div className={PANEL}>
<div className="flex items-center gap-2">
<span
className="min-w-0 flex-1 truncate font-geist font-bold uppercase tracking-[.08em] text-content-faint"
style={fs(9.5)}
>
{t('admin.addons.ai.parsing.installed')}
</span>
<button
type="button"
onClick={() => void P.loadModels()}
disabled={P.loadingModels}
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-card hover:text-content disabled:cursor-default disabled:opacity-60"
style={fs(11.5, 'body')}
>
<RefreshCw size={11} strokeWidth={2.2} className={P.loadingModels ? 'animate-spin' : undefined} />
{t('admin.addons.ai.parsing.refresh')}
</button>
</div>
{P.modelsErr && (
<p className="m-0 break-words text-danger" style={fs(11.5)}>
{P.modelsErr}
</p>
)}
{!P.modelsErr && P.installed.length === 0 && !P.loadingModels && (
<SettingsHint>{t('admin.addons.ai.parsing.noModels')}</SettingsHint>
)}
{P.installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{P.installed.map((name) => (
<button
type="button"
key={name}
title={name}
onClick={() => P.setModel(name)}
aria-pressed={P.model === name}
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${P.model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
style={fs(11.5)}
>
{name}
</button>
))}
</div>
)}
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
{t('admin.addons.ai.parsing.pullTitle')}
</span>
{RECOMMENDED_MODELS.map((m) => {
const here = P.isInstalled(m.id);
const chosen = P.model === m.id;
return (
<div key={m.id} className="min-w-0" title={m.note}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{m.label}
</span>
{m.recommended && <StatusPill tone="success">{t('assistant.model.recommended')}</StatusPill>}
{here ? (
<button
type="button"
onClick={() => P.setModel(m.id)}
disabled={chosen}
className={SETTINGS_BUTTON}
style={fs(12, 'body')}
>
{chosen ? <Check size={13} strokeWidth={2.4} /> : null}
{t(chosen ? 'admin.addons.ai.parsing.selected' : 'admin.addons.ai.parsing.use')}
</button>
) : (
<button
type="button"
onClick={() => void P.pull(m.id)}
disabled={!!P.pulling}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(12, 'body')}
>
{t(P.pulling === m.id ? 'admin.addons.ai.parsing.pulling' : 'admin.addons.ai.parsing.pull')}
</button>
)}
</div>
{P.pulling === m.id && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div
className="h-full rounded-full bg-accent transition-[width] duration-200"
style={{ width: `${P.pullPct}%` }}
/>
</div>
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>
{P.pullStatus}
{P.pullPct ? ` ${P.pullPct}%` : ''}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/** A connection of parsing's own: the provider as tiles, its address, key and model. */
function OwnConnectionForm({ P }: { P: Parsing }) {
const { t } = useTranslation();
const fieldId = useId();
return (
<div className={`flex flex-col gap-3 ${P.saving ? 'opacity-60' : ''}`}>
<div
role="radiogroup"
aria-label={t('settings.aiParsing.provider')}
className="grid grid-cols-3 gap-2 max-sm:grid-cols-1"
>
{OWN_PROVIDERS.map((p) => {
const on = P.provider === p.value;
return (
<button
key={p.value}
type="button"
role="radio"
aria-checked={on}
onClick={() => P.setProvider(p.value)}
className={`flex min-w-0 items-center gap-2 rounded-[12px] border p-2.5 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
>
<ProviderMark kind={p.mark} size="sm" />
<span
className={`min-w-0 flex-1 truncate font-medium ${on ? 'text-content' : 'text-content-muted'}`}
style={fs(12.5, 'body')}
>
{t(p.labelKey)}
</span>
</button>
);
})}
</div>
<EditorField
label={t('settings.aiParsing.baseUrl')}
htmlFor={`${fieldId}-url`}
hint={t('settings.aiParsing.baseUrlHint')}
>
<input
id={`${fieldId}-url`}
type="url"
autoComplete="off"
className={INPUT}
value={P.baseUrl}
onChange={(e) => P.setBaseUrl(e.target.value)}
onBlur={() => void P.loadModels()}
placeholder={P.baseUrlPlaceholder}
/>
</EditorField>
<EditorField
label={t('settings.aiParsing.apiKey')}
htmlFor={`${fieldId}-key`}
hint={P.provider === 'anthropic' ? ANTHROPIC_HINT : t('settings.aiParsing.apiKeyHint')}
>
<input
id={`${fieldId}-key`}
type="password"
autoComplete="off"
className={INPUT}
value={P.apiKey}
onChange={(e) => P.setApiKey(e.target.value)}
placeholder={
P.apiKey === MASKED ? MASKED : P.provider === 'local' ? t('admin.addons.ai.parsing.apiKeyOptional') : ''
}
/>
</EditorField>
<EditorField label={t('settings.aiParsing.model')} htmlFor={`${fieldId}-model`}>
<input
id={`${fieldId}-model`}
autoComplete="off"
className={`${INPUT} font-geist`}
value={P.model}
onChange={(e) => P.setModel(e.target.value)}
/>
</EditorField>
{P.provider === 'local' && <LocalModels P={P} />}
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
{!P.ownConnection && (
<button type="button" onClick={P.cancelOwn} disabled={P.saving} className={SETTINGS_BUTTON}>
{t('common.cancel')}
</button>
)}
<button
type="button"
onClick={() => void P.save()}
disabled={P.saving || !P.model.trim()}
className={SETTINGS_BUTTON_PRIMARY}
>
{t(P.saving ? 'common.saving' : 'common.save')}
</button>
</div>
</div>
);
}
/**
* AI parsing in the AI dialog: which connection reads the bookings (the shared
* one, or one of its own an older install kept), a connection of its own when
* wanted, and whether the model reads images. Each part saves on its own.
*/
export default function ParsingSettings({
addon,
A,
onOpenConnection,
}: {
addon: Addon;
A: AiAdmin;
onOpenConnection: () => void;
}): React.ReactElement {
const { t } = useTranslation();
const P = useLlmParsingConfig(addon);
const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) }));
const local =
(P.ownConnection ? P.savedProvider : A.instance.live?.kind === 'custom' ? 'local' : 'cloud') === 'local';
return (
<div className="flex flex-col gap-5">
<DialogSection label={t('admin.addons.ai.parsing.readsWith')}>
<ParsingSource A={A} P={P} onOpenConnection={onOpenConnection} />
</DialogSection>
{P.formOpen && (
<DialogSection label={t('admin.addons.ai.parsing.own')}>
<OwnConnectionForm P={P} />
</DialogSection>
)}
<DialogSection label={t('settings.aiParsing.multimodal')}>
<Segmented<LlmVision>
label={t('settings.aiParsing.multimodal')}
value={P.vision}
onChange={(v) => P.setVision(asLlmVision(v))}
options={visionOptions}
fill
/>
<div className="mt-1.5">
<SettingsHint>
{t(local ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
</SettingsHint>
</div>
</DialogSection>
</div>
);
}
@@ -0,0 +1,32 @@
import { Cable, type LucideIcon, MessagesSquare, ScanText } from 'lucide-react';
import type { Addon } from './addonModel';
/**
* The three addons that talk to a language model, shown as one AI tile in the
* integration column: the assistant in the trip, AI parsing (booking import)
* and MCP (TREK inside other AI apps). Their ids, rows and settings stay their
* own; only the page groups them. Assistant and parsing share one connection.
*/
const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const;
export type AiAddonId = (typeof AI_ADDON_IDS)[number];
/** Each one's glyph, keyed by id: the icon column of an existing install still holds what it was seeded with. */
export const AI_ADDON_ICONS: Record<AiAddonId, LucideIcon> = {
ai_assistant: MessagesSquare,
llm_parsing: ScanText,
mcp: Cable,
};
export function isAiAddon(addon: Addon): boolean {
return (AI_ADDON_IDS as readonly string[]).includes(addon.id);
}
/** The AI addons this install has, in the tile's order. */
export function aiAddonsOf(addons: Addon[]): Addon[] {
return AI_ADDON_IDS.flatMap((id) => addons.find((a) => a.id === id) ?? []);
}
/** Whether the shared connection is used at all: the assistant or AI parsing is on. */
export function usesConnection(addons: Addon[]): boolean {
return addons.some((a) => (a.id === 'ai_assistant' || a.id === 'llm_parsing') && a.enabled);
}
@@ -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={'![pixel](https://evil.example/p.gif)\n\n<img src="https://evil.example/q.gif">'} />
);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('picture, video, audio, iframe')).toBeNull();
});
it('FE-AI-MD-003: leaves no javascript: link to click', () => {
const { container } = render(<AssistantMarkdown text={'[open](javascript:alert(1))'} />);
const link = container.querySelector('a');
expect(link?.getAttribute('href') ?? '').not.toMatch(/^javascript:/i);
});
it('FE-AI-MD-004: an outside link opens in its own tab and names its host', () => {
render(<AssistantMarkdown text={'[Tickets](https://www.kinkaku-ji.or.jp/en/)'} />);
const link = screen.getByRole('link', { name: /Tickets/ });
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
expect(link).toHaveTextContent('(www.kinkaku-ji.or.jp)');
});
it('FE-AI-MD-005: a link into TREK stays in the app, and the writing caret is marked', () => {
const { container } = render(<AssistantMarkdown text={'See [day 2](/trips/4)'} writing />);
const link = screen.getByRole('link', { name: 'day 2' });
expect(link).toHaveAttribute('href', '/trips/4');
expect(link).not.toHaveAttribute('target');
expect(container.firstElementChild).toHaveAttribute('data-writing', 'true');
});
});
@@ -0,0 +1,64 @@
import { memo } from 'react';
import Markdown, { type Components } from 'react-markdown';
import { Link } from 'react-router';
import remarkGfm from 'remark-gfm';
import { sanitizedMarkdownPlugins } from '../shared/markdownSanitize';
/**
* An answer's text, as Markdown. The model writes it, and what the model reads
* (trip notes, place descriptions, search results) can carry someone else's
* words, so it renders like untrusted input: raw HTML is skipped and
* sanitized, and nothing that loads on its own (an image is a tracking pixel
* that leaks a request to any host) is drawn at all.
*
* A link shows where it goes: an outside one names its host beside the text
* and opens in its own tab, a link into TREK stays in the app.
*/
const DISALLOWED = ['img', 'picture', 'source', 'video', 'audio', 'iframe', 'object', 'embed'];
function hostOf(href: string): string | null {
try {
return new URL(href).host || null;
} catch {
return null;
}
}
const components: Components = {
a: ({ href, children }) => {
if (href?.startsWith('/') && !href.startsWith('//')) return <Link to={href}>{children}</Link>;
const host = href ? hostOf(href) : null;
const text = typeof children === 'string' ? children : null;
return (
<a href={href} target="_blank" rel="noopener noreferrer nofollow">
{children}
{host && text !== host && text !== href && <span className="text-content-muted"> ({host})</span>}
</a>
);
},
};
function AssistantMarkdown({ text, writing = false }: { text: string; writing?: boolean }) {
return (
<div
dir="auto"
data-writing={writing || undefined}
className="trek-assistant-md break-words text-body text-content"
>
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={sanitizedMarkdownPlugins}
disallowedElements={DISALLOWED}
skipHtml
components={components}
>
{text}
</Markdown>
</div>
);
}
// An answer re-renders with every streamed word; a block whose text did not change keeps its parse.
export default memo(AssistantMarkdown);
@@ -0,0 +1,112 @@
import { NotebookPen, Plus } from 'lucide-react';
import { useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { TEXTAREA } from '../shared/dialogParts';
import { MAX_MEMORY_CHARS, useTripMemory } from './useTripMemory';
/** What travellers most often want minded, one tap to add each. */
const QUICK = ['vegetarian', 'kids', 'budget', 'slow', 'accessible', 'noMuseums'] as const;
/**
* The trip's notes for the assistant: a few lines it follows in every
* suggestion on this trip, written once instead of in every question. The
* common ones are a tap away; the rest is free text.
*/
export default function AssistantMemoryDialog({ tripId, onClose }: { tripId: number; onClose: () => void }) {
const { t } = useTranslation();
const labelId = useId();
const memory = useTripMemory(tripId);
const [draft, setDraft] = useState(memory.text);
const [saving, setSaving] = useState(false);
const add = (line: string) =>
setDraft((prev) => (prev.includes(line) ? prev : `${prev.trimEnd()}${prev.trim() ? '\n' : ''}${line}`));
const save = async () => {
setSaving(true);
const done = await memory.save(draft);
setSaving(false);
if (done) onClose();
};
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="narrow"
discardGuard={draft}
header={
<DialogHeader
tile={
<DialogTile>
<NotebookPen size={20} strokeWidth={2} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
eyebrow={t('assistant.launcher.tooltip')}
title={t('assistant.memory.title')}
sub={t('assistant.memory.sub')}
subWraps
onClose={onClose}
/>
}
footer={
<DialogFooter>
<span className="text-caption tabular-nums text-content-faint">
{draft.length}/{MAX_MEMORY_CHARS}
</span>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" disabled={saving} onClick={() => void save()}>
{t(saving ? 'common.saving' : 'common.save')}
</DialogButton>
</DialogFooter>
}
>
<DialogSection label={t('assistant.memory.quick')}>
<div className="flex flex-wrap gap-1.5">
{QUICK.map((key, i) => {
const line = t(`assistant.memory.quick.${key}`);
const taken = draft.includes(line);
return (
<button
key={key}
type="button"
disabled={taken}
onClick={() => add(line)}
className="trek-assistant-enter inline-flex items-center gap-1 rounded-full bg-surface-card px-2.5 py-1 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md disabled:translate-y-0 disabled:opacity-40 disabled:shadow-none"
style={{ animationDelay: `${i * 40}ms` }}
>
<Plus size={11} aria-hidden="true" />
{line}
</button>
);
})}
</div>
</DialogSection>
<DialogSection label={t('assistant.memory.notes')}>
<textarea
aria-label={t('assistant.memory.notes')}
rows={6}
maxLength={MAX_MEMORY_CHARS}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={t('assistant.memory.placeholder')}
className={TEXTAREA}
/>
</DialogSection>
</DialogShell>
);
}
@@ -0,0 +1,133 @@
import {
ComposerPrimitive,
type TriggerAdapter,
type TriggerItem,
unstable_useTriggerPopoverScopeContext as useTriggerScope,
} from '@assistant-ui/react';
import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Ticket } from 'lucide-react';
import { type MouseEvent, useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { MENTION_FORMATTER, MENTION_KINDS, type MentionKind } from './assistantMentionModel';
import { useAssistantView } from './assistantViewContext';
/** The icon of a day, place or booking in the `@` list. */
function MentionKindIcon({ kind, size }: { kind: string; size: number }) {
const props = { size, 'aria-hidden': true, className: 'flex-shrink-0' } as const;
if (kind === 'day') return <CalendarDays {...props} />;
if (kind === 'reservation') return <Ticket {...props} />;
return <MapPin {...props} />;
}
/** A row takes the click without the focus, so the caret stays in the text and typing goes on. */
const keepFocus = (e: MouseEvent) => e.preventDefault();
/**
* The list `@` opens over the composer: first the kinds (days, places,
* bookings), and once letters follow the `@`, the entries that match across
* all of them. The focus never leaves the text; arrows walk the list, Enter
* or Tab picks, Escape closes it and keeps what was typed.
*/
export default function AssistantMentionPopover({
adapter,
onInserted,
}: {
/** Undefined while the trip's lists are not at hand; `@` then opens nothing. */
adapter: TriggerAdapter | undefined;
onInserted: (item: TriggerItem) => void;
}) {
const { t } = useTranslation();
const { variant } = useAssistantView();
const phone = variant === 'fullscreen';
return (
<ComposerPrimitive.TriggerPopover
char="@"
adapter={adapter}
aria-label={t('assistant.mention.hint')}
className={`absolute bottom-full end-0 start-0 z-30 mb-1.5 overflow-y-auto rounded-xl border border-edge-faint bg-surface-card p-1 shadow-dropdown ${phone ? 'max-h-[40vh]' : 'max-h-56'}`}
>
<ComposerPrimitive.TriggerPopover.Directive formatter={MENTION_FORMATTER} onInserted={onInserted} />
<MentionList row={phone ? 'min-h-[44px]' : 'min-h-[32px]'} />
</ComposerPrimitive.TriggerPopover>
);
}
const ROW =
'flex w-full items-center gap-2 rounded-lg px-2 text-start text-body text-content transition-colors hover:bg-surface-hover data-[highlighted]:bg-surface-hover';
function MentionList({ row }: { row: string }) {
const { t } = useTranslation();
const scope = useTriggerScope();
const listRef = useRef<HTMLDivElement>(null);
// The highlighted row stays in view while the arrows walk past the list's edge.
useEffect(() => {
const hot = listRef.current?.querySelector<HTMLElement>('[data-highlighted]');
hot?.scrollIntoView?.({ block: 'nearest' });
}, [scope.highlightedIndex, scope.items, scope.activeCategoryId]);
if (!scope.open) return null;
const listing = scope.isSearchMode || scope.activeCategoryId !== null;
const active = scope.activeCategoryId ? MENTION_KINDS[scope.activeCategoryId as MentionKind] : undefined;
const heading = active ? t(active.labelKey) : null;
return (
<div ref={listRef}>
{heading && (
<ComposerPrimitive.TriggerPopoverBack
onMouseDown={keepFocus}
className="flex w-full items-center gap-1 rounded-lg px-1.5 py-1 text-caption font-semibold text-content-muted hover:text-content"
>
<ChevronLeft size={13} aria-hidden="true" className="rtl:-scale-x-100" />
{heading}
</ComposerPrimitive.TriggerPopoverBack>
)}
<ComposerPrimitive.TriggerPopoverCategories aria-label={t('assistant.mention.hint')}>
{(categories) =>
categories.map((c) => (
<ComposerPrimitive.TriggerPopoverCategoryItem
key={c.id}
categoryId={c.id}
onMouseDown={keepFocus}
className={`${ROW} ${row}`}
>
<span className="flex text-content-muted">
<MentionKindIcon kind={c.id} size={14} />
</span>
<span className="flex-1 truncate">{c.label}</span>
<ChevronRight
size={13}
aria-hidden="true"
className="flex-shrink-0 text-content-faint rtl:-scale-x-100"
/>
</ComposerPrimitive.TriggerPopoverCategoryItem>
))
}
</ComposerPrimitive.TriggerPopoverCategories>
<ComposerPrimitive.TriggerPopoverItems aria-label={heading ?? t('assistant.mention.hint')}>
{(items) =>
items.map((item, index) => (
<ComposerPrimitive.TriggerPopoverItem
key={item.id}
item={item}
index={index}
onMouseDown={keepFocus}
className={`${ROW} ${row}`}
>
<span className="flex text-content-muted">
<MentionKindIcon kind={item.type} size={14} />
</span>
<bdi className="min-w-0 flex-1 truncate">{item.label}</bdi>
{item.description && (
<span className="flex-shrink-0 text-caption text-content-faint">{item.description}</span>
)}
</ComposerPrimitive.TriggerPopoverItem>
))
}
</ComposerPrimitive.TriggerPopoverItems>
{listing && scope.items.length === 0 && (
<p className="m-0 px-2 py-1.5 text-caption text-content-muted">{t('assistant.mention.none')}</p>
)}
</div>
);
}
@@ -0,0 +1,42 @@
/**
* FE-AI-MENTION-TEXT-001 to FE-AI-MENTION-TEXT-002
*
* A sent question with the days, places and bookings it named set off as
* chips, and every other `@` left as text.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import AssistantMentionText from './AssistantMentionText';
describe('AssistantMentionText', () => {
it('FE-AI-MENTION-TEXT-001: sets off each named @label and keeps the words around it', () => {
const { container } = render(
<p>
<AssistantMentionText
text="Is @Louvre open on @Arrival?"
items={[
{ kind: 'place', id: 21, label: 'Louvre' },
{ kind: 'day', id: 11, label: 'Arrival' },
]}
/>
</p>
);
expect(container.querySelector('p')?.textContent).toBe('Is @Louvre open on @Arrival?');
expect(screen.getByText('@Louvre').tagName).toBe('BDI');
expect(screen.getByText('@Arrival').tagName).toBe('BDI');
});
it('FE-AI-MENTION-TEXT-002: an @ that names nothing stays plain text', () => {
const { container } = render(
<p>
<AssistantMentionText
text="Write to me @ home or @Louvre2"
items={[{ kind: 'place', id: 21, label: 'Louvre' }]}
/>
</p>
);
expect(container.querySelector('p')?.textContent).toBe('Write to me @ home or @Louvre2');
expect(container.querySelector('bdi')).toBeNull();
});
});
@@ -0,0 +1,33 @@
import type { AssistantMentions } from '@trek/shared';
import { useMemo } from 'react';
import { splitMentions } from './assistantMentionModel';
/**
* A sent question as the user wrote it, with every `@label` it named a day,
* place or booking with set off as a quiet inline chip. The labels come from
* the question's own mentions part, so a stray `@` in the text stays text.
*/
export default function AssistantMentionText({ text, items }: { text: string; items: AssistantMentions['items'] }) {
const segments = useMemo(
() =>
splitMentions(
text,
items.map((i) => i.label)
),
[text, items]
);
return (
<>
{segments.map((s) =>
s.mention ? (
<bdi key={s.at} className="rounded-md bg-surface-card px-1 font-medium text-content">
{s.text}
</bdi>
) : (
<span key={s.at}>{s.text}</span>
)
)}
</>
);
}
@@ -0,0 +1,162 @@
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
import type { AssistantMessage as StoredMessage } from '@trek/shared';
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { Tooltip } from '../shared/Tooltip';
import AssistantChangeCard from './AssistantChangeCard';
import AssistantFollowUps from './AssistantFollowUps';
import AssistantMarkdown from './AssistantMarkdown';
import AssistantMentionText from './AssistantMentionText';
import AssistantPlaceStrip from './AssistantPlaceStrip';
import AssistantShareDialog from './AssistantShareDialog';
import AssistantToolSteps from './AssistantToolSteps';
import AssistantWeatherStrip from './AssistantWeatherStrip';
import { messageBlocks } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { useAssistantShare } from './useAssistantShare';
/**
* One message of the chat. The user's question sits on the end side in a
* quiet accent bubble; the answer runs as plain text across the column, with
* its looked-up steps, the places and weather they found, and the proposed
* changes in between, and a copy and a share button that show on hover
* (always on the latest answer, and always on the phone, which has no hover).
*/
const BAR_BUTTON =
'rounded-md p-1 text-content-faint hover:bg-surface-hover hover:text-content-secondary disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent';
/** Posts the answer to the trip chat after a preview; a tick once it went. */
function ShareButton({ share }: { share: ReturnType<typeof useAssistantShare> }) {
const { t } = useTranslation();
if (share.shared) {
return (
<Tooltip label={t('assistant.share.shared')}>
<span className="inline-flex p-1 text-content-faint">
<Check size={14} aria-hidden="true" />
<span className="sr-only">{t('assistant.share.shared')}</span>
</span>
</Tooltip>
);
}
const label = share.offline ? t('assistant.error.offline') : t('assistant.share.button');
return (
<Tooltip label={label}>
{/* The wrapper carries the tooltip while the button is off. */}
<span className="inline-flex">
<button
type="button"
onClick={share.open}
disabled={share.offline || share.sending}
aria-label={t('assistant.share.button')}
className={BAR_BUTTON}
>
{share.sending ? (
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
) : (
<Share2 size={14} aria-hidden="true" />
)}
</button>
</span>
</Tooltip>
);
}
/** A file the user handed over, as a chip with what it is and how big. */
function AttachmentChip({ name, size }: { name: string; size: number }) {
const kb = Math.max(1, Math.round(size / 1024));
return (
<span className="inline-flex max-w-full items-center gap-2.5 rounded-xl border border-edge-faint bg-surface-card py-2 pe-3.5 ps-2 shadow-card">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-content-secondary">
<FileText size={16} aria-hidden="true" />
</span>
<span className="flex min-w-0 flex-col">
<bdi className="truncate text-body font-medium text-content">{name}</bdi>
<span className="text-caption tabular-nums text-content-muted">
{kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb} KB`}
</span>
</span>
</span>
);
}
function Question({ message }: { message: StoredMessage }) {
const text = message.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
const files = message.parts.flatMap((p) => (p.type === 'attachment' ? [p] : []));
const mentions = message.parts.flatMap((p) => (p.type === 'data-trek-mentions' ? p.data.items : []));
return (
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col items-end gap-1.5 py-1.5">
{files.map((f, i) => (
<AttachmentChip key={`${f.name}-${i}`} name={f.name} size={f.size} />
))}
{text && (
<p
dir="auto"
className="m-0 max-w-[85%] whitespace-pre-wrap break-words rounded-xl bg-accent-subtle px-3.5 py-2 text-body text-content"
>
{mentions.length > 0 ? <AssistantMentionText text={text} items={mentions} /> : text}
</p>
)}
</MessagePrimitive.Root>
);
}
function Answer({ message }: { message: StoredMessage }) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
const writing = useAssistantStore((s) => {
const chat = chatOf(s, tripId);
return chat.isRunning && chat.messages[chat.messages.length - 1]?.id === message.id;
});
const blocks = useMemo(() => messageBlocks(message, writing), [message, writing]);
const share = useAssistantShare(tripId, message, writing);
const last = blocks[blocks.length - 1];
const hasText = blocks.some((b) => b.kind === 'text');
return (
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
{blocks.map((block) => {
if (block.kind === 'text') {
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
}
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
if (block.kind === 'places') {
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
}
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
return <AssistantChangeCard key={block.key} items={block.items} />;
})}
{/* Nothing below says the answer goes on: no words being written, no step running. */}
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
<p role="status" className="m-0 text-caption">
<span className="trek-assistant-shimmer">{t('assistant.step.working')}</span>
</p>
)}
{hasText && !writing && (
// The bar mounts on hover for older answers; the row keeps its height so nothing jumps.
<div className="-mt-1 h-6">
<ActionBarPrimitive.Root autohide={variant === 'fullscreen' ? 'never' : 'not-last'} className="-ms-1 flex">
<ActionBarPrimitive.Copy aria-label={t('common.copy')} className={BAR_BUTTON}>
<AuiIf condition={(s) => s.message.isCopied}>
<Check size={14} aria-hidden="true" />
</AuiIf>
<AuiIf condition={(s) => !s.message.isCopied}>
<Copy size={14} aria-hidden="true" />
</AuiIf>
</ActionBarPrimitive.Copy>
{share.canShare && <ShareButton share={share} />}
</ActionBarPrimitive.Root>
</div>
)}
{share.canShare && <AssistantShareDialog share={share} />}
<AssistantFollowUps message={message} />
</MessagePrimitive.Root>
);
}
export default function AssistantMessage({ message }: { message: StoredMessage }) {
return message.role === 'user' ? <Question message={message} /> : <Answer message={message} />;
}
@@ -0,0 +1,290 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { getCategoryIcon } from '../shared/categoryIcons';
import { PopoverListPanel } from '../shared/dialogParts';
import { Tooltip } from '../shared/Tooltip';
import { usePopoverList } from '../shared/usePopoverList';
import { useAssistantView } from './assistantViewContext';
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
type Strip = ReturnType<typeof useAssistantPlaceStrip>;
const ACTION =
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
/** "+ Day 3" for the day in focus, a list of the trip's days beside it; "Pick a day" when no day is in focus. */
function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
const { t } = useTranslation();
const { days, focusDay, disabled, blockedKey, adding } = strip;
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
});
const { open, toggle, close, triggerRef, listId } = list;
const height = tall ? 'h-10' : 'h-7';
const busy = adding === place.ref;
const pick = (dayId: number) => {
close();
void strip.suggest(place, dayId);
};
const buttons = (
<span className="inline-flex min-w-0 gap-px">
{focusDay ? (
<>
<button
type="button"
disabled={disabled}
onClick={() => void strip.suggest(place, focusDay.id)}
aria-label={t('assistant.place.addTo', { day: focusDay.label })}
className={`${ACTION} ${height} rounded-e-none px-2`}
>
{busy ? (
<LoaderCircle size={12} aria-hidden="true" className="animate-spin" />
) : (
<Plus size={12} aria-hidden="true" />
)}
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
</button>
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-label={t('assistant.place.pickDay')}
className={`${ACTION} ${height} rounded-s-none px-1.5`}
>
<ChevronDown size={12} aria-hidden="true" />
</button>
</>
) : (
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
className={`${ACTION} ${height} px-2`}
>
<Plus size={12} aria-hidden="true" />
<span className="truncate">{t('assistant.place.pickDay')}</span>
<ChevronDown size={12} aria-hidden="true" className="text-content-faint" />
</button>
)}
</span>
);
return (
<>
{blockedKey ? (
<Tooltip label={t(blockedKey)} placement="top">
{/* A disabled button gets no pointer events; the wrapper carries the reason. */}
<span className="inline-flex min-w-0">{buttons}</span>
</Tooltip>
) : (
buttons
)}
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
<div className="grid grid-cols-7 gap-1">
{days.map((day) => {
const current = day.id === focusDay?.id;
return (
<Tooltip key={day.id} label={day.label} placement="top">
<button
type="button"
onClick={() => pick(day.id)}
aria-label={day.label}
aria-pressed={current}
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
}`}
>
<span className="text-body font-semibold tabular-nums">{day.n}</span>
{day.weekday && (
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
{day.weekday}
</span>
)}
</button>
</Tooltip>
);
})}
</div>
</PopoverListPanel>
</>
);
}
type Category = ReturnType<Strip['categoryOf']>;
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
function PlaceMedia({
place,
photo,
category,
}: {
place: AssistantFoundPlace;
photo: string | null;
category: Category;
}) {
const { t } = useTranslation();
const icon = category ? getCategoryIcon(category.icon) : MapPin;
return (
<span
className="relative block h-24 w-full overflow-hidden bg-surface-secondary"
// Without a picture the category's own colour washes the space, data from the trip.
style={
photo || !category
? undefined
: { background: `color-mix(in srgb, ${category.color} 16%, var(--bg-secondary))` }
}
>
{photo ? (
<img
src={photo}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-cover transition-transform duration-300 group-hover/card:scale-[1.04]"
/>
) : (
<span className="grid h-full w-full place-items-center">
{createElement(icon, {
size: 30,
'aria-hidden': true,
style: { color: category?.color ?? 'var(--text-faint)' },
})}
</span>
)}
{category && photo && (
<span className="absolute start-2 top-2 inline-flex max-w-[75%] items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content shadow-sm">
{createElement(icon, {
size: 11,
'aria-hidden': true,
className: 'flex-shrink-0',
style: { color: category.color },
})}
<bdi className="truncate">{category.name}</bdi>
</span>
)}
{place.openNow !== null && (
<span className="absolute bottom-2 end-2 inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content-muted shadow-sm">
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${place.openNow ? 'bg-success' : 'bg-[color:var(--text-faint)]'}`}
/>
{t(place.openNow ? 'assistant.place.openNow' : 'assistant.place.closed')}
</span>
)}
</span>
);
}
/** The card's one way on: "On map" for a place the trip has, "Suggested" once it is, else "+ Day 3". */
function PlaceAction({
place,
strip,
suggested,
tall,
}: {
place: AssistantFoundPlace;
strip: Strip;
suggested: boolean;
tall: boolean;
}): ReactNode {
const { t } = useTranslation();
if (place.inTripPlaceId !== null) {
return (
<Tooltip label={t('assistant.place.inTrip')} placement="top">
<button type="button" onClick={() => strip.show(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
<MapPin size={12} aria-hidden="true" />
{t('assistant.place.showOnMap')}
</button>
</Tooltip>
);
}
if (suggested) {
return (
<span className="inline-flex h-7 items-center gap-1 text-caption text-content-muted">
<Check size={12} aria-hidden="true" className="text-success" />
{t('assistant.place.suggested')}
</span>
);
}
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
}
/**
* One place a search found, as a card: its picture from TREK's photo proxy
* with the category it most likely belongs in on it (or that category's icon
* on its colour), its name, address and rating, whether it is open now while
* the trip runs, how far it is from the last stop of the day in focus, and one
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
* the card shows it on the map, and resting the pointer on it pins it there
* for a moment.
*/
export default function AssistantPlaceCard({
place,
strip,
suggested,
}: {
place: AssistantFoundPlace;
strip: Strip;
suggested: boolean;
}) {
const { t } = useTranslation();
const { variant, density } = useAssistantView();
const ref = useRef<HTMLLIElement>(null);
const photo = useFoundPlacePhoto(place, ref);
const category = strip.categoryOf(place);
const distance = strip.distanceOf(place);
const tall = variant === 'fullscreen';
return (
<li
ref={ref}
className={`group/card flex flex-shrink-0 snap-start flex-col overflow-hidden rounded-2xl border border-edge-faint bg-surface-card shadow-sm transition-shadow hover:shadow-md ${
density === 'compact' ? 'w-[188px]' : 'w-[220px]'
}`}
onMouseEnter={() => strip.hover(place)}
onMouseLeave={() => strip.hover(null)}
>
<button
type="button"
onClick={() => strip.show(place)}
onFocus={() => strip.hover(place)}
onBlur={() => strip.hover(null)}
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
>
<PlaceMedia place={place} photo={photo} category={category} />
<span className="flex min-w-0 flex-col gap-1 px-3 pt-2.5">
<bdi className="line-clamp-2 text-body font-semibold leading-snug text-content">{place.name}</bdi>
{category && !photo && <bdi className="truncate text-caption text-content-muted">{category.name}</bdi>}
{place.address && <bdi className="truncate text-caption text-content-muted">{place.address}</bdi>}
{(place.rating !== null || distance) && (
<span className="flex min-w-0 items-center gap-2 text-caption text-content-faint">
{place.rating !== null && (
<span className="inline-flex flex-shrink-0 items-center gap-0.5 font-medium text-content-muted">
<Star size={11} aria-hidden="true" className="fill-current text-warning" />
{place.rating.toFixed(1)}
</span>
)}
{distance && <span className="truncate">{t('assistant.place.fromPrev', { distance })}</span>}
</span>
)}
</span>
</button>
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
</div>
</li>
);
}
@@ -0,0 +1,234 @@
/**
* FE-AI-PLACES-001 to FE-AI-PLACES-008
*
* The strip of places a search found: what a card says, that its picture only
* ever comes from TREK's photo proxy, "+ Day N" for the day in focus or a
* picked one, why it waits while a change is undecided, and the map's part.
*/
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildAssignment,
buildCategory,
buildDay,
buildPlace,
buildTrip,
buildUser,
} from '../../../tests/helpers/factories';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { fetchPhoto } from '../../services/photoService';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { setForcedOffline } from '../../sync/networkMode';
import AssistantPlaceStrip from './AssistantPlaceStrip';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
vi.mock('../../services/photoService', () => ({
fetchPhoto: vi.fn(),
getCached: vi.fn(() => undefined),
}));
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
const AUTH = useAuthStore.getState();
const RealObserver = globalThis.IntersectionObserver;
const DAY_ONE = buildDay({ id: 31, date: '2026-11-02' });
const DAY_TWO = buildDay({ id: 32, date: '2026-11-03' });
function place(ref: string, extra: Partial<AssistantFoundPlace> = {}): AssistantFoundPlace {
return {
ref,
name: `Trattoria ${ref}`,
address: null,
lat: 41.889,
lng: 12.47,
kind: 'food',
rating: null,
openNow: null,
googlePlaceId: null,
osmId: null,
photoKey: null,
inTripPlaceId: null,
...extra,
};
}
const answer = (parts: AssistantMessage['parts'] = []): AssistantMessage => ({
id: 'msg-5',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-21T08:00:00.000Z',
parts,
});
const addFoundPlace = vi.fn(async () => {});
const requestCamera = vi.fn();
const setPreviewPin = vi.fn();
const onRevealMap = vi.fn();
function seed({ focusDayId = 31, messages = [] as AssistantMessage[] } = {}) {
useAuthStore.setState({ user: buildUser({ id: 1 }), placesPhotosEnabled: true });
useTripStore.setState({
trip: buildTrip({ id: TRIP, user_id: 1 }),
days: [DAY_ONE, DAY_TWO],
assignments: {
'31': [buildAssignment({ order_index: 0, place: buildPlace({ lat: 41.9, lng: 12.47 }) })],
},
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
});
useAssistantStore.setState({
addFoundPlace,
requestCamera,
setPreviewPin,
byTrip: {
[TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7, focus: { dayId: focusDayId }, messages },
},
});
}
function renderStrip(found: AssistantPlaces, message = answer(), view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', density: 'compact', onRevealMap, ...view };
return render(
<AssistantViewContext.Provider value={value}>
<AssistantPlaceStrip found={found} message={message} />
</AssistantViewContext.Provider>
);
}
beforeEach(() => setForcedOffline(false));
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useTripStore.setState(TRIPS, true);
useAuthStore.setState(AUTH, true);
globalThis.IntersectionObserver = RealObserver;
setForcedOffline(false);
vi.clearAllMocks();
});
describe('AssistantPlaceStrip', () => {
it('FE-AI-PLACES-001: a card names the place, its likely category, whether it is open and how far it is', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0', { openNow: true })], more: 0 });
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
expect(screen.getByText('Restaurant')).toBeInTheDocument();
expect(screen.getByText('Open now')).toBeInTheDocument();
expect(screen.getByText('1.2 km from the previous stop')).toBeInTheDocument();
});
it('FE-AI-PLACES-002: shows every card it was sent and counts what the server left out on a tile', () => {
seed();
const list = Array.from({ length: 8 }, (_, i) => place(`s1:${i}`));
renderStrip({ toolCallId: 's1', places: list, more: 4 });
expect(screen.getAllByRole('button', { name: /^Add to/ })).toHaveLength(8);
expect(screen.getByText('4 more')).toBeInTheDocument();
});
it('FE-AI-PLACES-009: a place known only by its position shows its category, never a photo of the area', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0', { photoKey: 'coords:41.889000,12.470000' })], more: 0 });
expect(fetchPhoto).not.toHaveBeenCalled();
});
it('FE-AI-PLACES-003: a picture only ever comes from the photo proxy', async () => {
seed();
globalThis.IntersectionObserver = class {
private readonly callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
}
observe() {
this.callback([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
}
disconnect() {}
unobserve() {}
takeRecords() {
return [];
}
} as unknown as typeof IntersectionObserver;
vi.mocked(fetchPhoto).mockImplementation((key, _id, _lat, _lng, _name, callback) => {
const photoUrl = key === 'good' ? '/api/maps/place-photo/good/bytes' : 'https://evil.example/x.jpg';
callback?.({ photoUrl, thumbDataUrl: null });
});
const { container } = renderStrip({
toolCallId: 's1',
places: [place('s1:0', { photoKey: 'good' }), place('s1:1', { photoKey: 'bad', lat: 40 })],
more: 0,
});
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1));
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/maps/place-photo/good/bytes');
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
});
it('FE-AI-PLACES-004: "+ Day 1" suggests the place for the day in focus', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: /^Add to/ }));
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 31);
});
it('FE-AI-PLACES-005: another day is picked from the list, and without a focus the list is the only way', () => {
seed({ focusDayId: 0 });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: /Pick a day/ }));
fireEvent.click(screen.getByRole('button', { name: /Nov 3/ }));
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 32);
});
it('FE-AI-PLACES-006: waits while a change is undecided, and offline', () => {
const waiting = answer([
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
]);
seed({ messages: [waiting] });
const { unmount } = renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
unmount();
seed();
setForcedOffline(true);
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
});
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
seed();
const suggested = answer([
{
type: 'tool-call',
toolCallId: 'f1',
toolName: 'add_found_place',
args: { lat: 41.889, lng: 12.47 },
approval: { id: 'f1', approved: true },
},
]);
renderStrip(
{ toolCallId: 's1', places: [place('s1:0'), place('s1:1', { lat: 40, inTripPlaceId: 8 })], more: 0 },
suggested,
{ variant: 'fullscreen' }
);
expect(screen.getByText('Suggested')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'On map' }));
expect(onRevealMap).toHaveBeenCalled();
expect(requestCamera).toHaveBeenCalledWith(TRIP, [[40, 12.47]], 'pan', 'user');
});
it('FE-AI-PLACES-008: resting on a card pins it on the map until the pointer leaves', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
const card = screen.getByText('Trattoria s1:0').closest('li');
if (!card) throw new Error('no card');
fireEvent.mouseEnter(card);
expect(setPreviewPin).toHaveBeenLastCalledWith({
tripId: TRIP,
ref: 's1:0',
lat: 41.889,
lng: 12.47,
name: 'Trattoria s1:0',
});
fireEvent.mouseLeave(card);
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
});
});
@@ -0,0 +1,132 @@
import type { AssistantMessage, AssistantPlaces } from '@trek/shared';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import AssistantPlaceCard from './AssistantPlaceCard';
import { suggestedFromStrip } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
/** Cards the strip shows, every one the server sends; what it left out is counted on the tile at its end. */
const SHOWN = 60;
// In the strip's head, never over a card: a press that ends a pixel off still lands on the arrow.
const ARROW =
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content disabled:cursor-default disabled:opacity-40 disabled:hover:text-content-secondary';
/** How far the strip has scrolled from either end, with the reading direction in mind. */
function edgesOf(el: HTMLElement): { start: boolean; end: boolean } {
// A right-to-left strip scrolls into negative numbers; the distance from the start is the same either way.
const from = Math.abs(el.scrollLeft);
return { start: from > 4, end: from + el.clientWidth < el.scrollWidth - 4 };
}
/**
* The places a search found, as one row of cards to swipe or scroll through:
* the next card peeks in, so the row says it goes on, and the edges fade
* where there is more. On the desktop a head over the row names it and holds
* two arrows that move it by whole cards, off while that way is the end. Every card the step found, then a
* tile counting what the server left out.
*/
export default function AssistantPlaceStrip({ found, message }: { found: AssistantPlaces; message: AssistantMessage }) {
const { t } = useTranslation();
const { variant } = useAssistantView();
const strip = useAssistantPlaceStrip();
const listRef = useRef<HTMLUListElement>(null);
const [fade, setFade] = useState({ start: false, end: false });
const rest = Math.max(0, found.places.length - SHOWN) + found.more;
const measure = useCallback(() => {
const el = listRef.current;
if (!el) return;
const next = edgesOf(el);
setFade((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
}, []);
useEffect(() => {
measure();
const el = listRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure, found]);
// The pin a card left on the map goes with the strip.
const { hover } = strip;
useEffect(() => () => hover(null), [hover]);
/**
* Scrolls by as many whole cards as the view holds, towards the start (-1)
* or the end (1), so the row lands on a card edge instead of fighting its
* snap points half way; smoothly, unless motion is turned down.
*/
const page = (towards: -1 | 1) => {
const el = listRef.current;
const card = el?.firstElementChild as HTMLElement | null;
if (!el || !card) return;
const gap = Number.parseFloat(getComputedStyle(el).columnGap) || 0;
const step = card.offsetWidth + gap;
const cards = Math.max(1, Math.floor((el.clientWidth - 32) / step));
const rtl = getComputedStyle(el).direction === 'rtl';
// Smooth through the strip's own scroll-behavior, which reduced motion turns off.
el.scrollBy((rtl ? -towards : towards) * cards * step, 0);
};
const total = found.places.length + found.more;
return (
<section aria-label={t('assistant.place.strip')} className="-mx-4">
{variant === 'window' && (fade.start || fade.end) && (
// The cards are reachable by Tab; the arrows are for the pointer only.
<div className="mb-1.5 flex items-center gap-2 px-4">
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-muted">
{t('assistant.place.strip')}
<span className="ms-1.5 tabular-nums text-content-faint">{total}</span>
</span>
<button
type="button"
tabIndex={-1}
aria-hidden="true"
disabled={!fade.start}
onClick={() => page(-1)}
className={ARROW}
>
<ChevronLeft size={15} className="rtl:-scale-x-100" />
</button>
<button
type="button"
tabIndex={-1}
aria-hidden="true"
disabled={!fade.end}
onClick={() => page(1)}
className={ARROW}
>
<ChevronRight size={15} className="rtl:-scale-x-100" />
</button>
</div>
)}
<ul
ref={listRef}
onScroll={measure}
data-fade-start={fade.start || undefined}
data-fade-end={fade.end || undefined}
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5 motion-safe:scroll-smooth"
>
{found.places.slice(0, SHOWN).map((place) => (
<AssistantPlaceCard
key={place.ref}
place={place}
strip={strip}
suggested={suggestedFromStrip(message, place)}
/>
))}
{rest > 0 && (
<li className="flex w-24 flex-shrink-0 snap-start items-center justify-center rounded-2xl border border-dashed border-edge-faint px-2 text-center text-caption text-content-muted">
{t('assistant.place.more', { count: rest })}
</li>
)}
</ul>
</section>
);
}
@@ -0,0 +1,259 @@
/**
* FE-AI-POWER-001 to FE-AI-POWER-010
*
* The parts that tie the chat into the planner: the next questions under an
* answer, the waiting changes inside the day list, the week grid of a plan
* over several days, the context menu rows, the trip notes and the part of the
* map a question carries.
*/
import { act, renderHook } from '@testing-library/react';
import type { AssistantChangePreview, AssistantMessage } from '@trek/shared';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { TranslationProvider } from '../../i18n/TranslationContext';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import { mapViewOf } from '../Map/assistantViewport';
import AssistantDayPreview from './AssistantDayPreview';
import { followUpsFor } from './assistantFollowUpRules';
import AssistantFollowUps from './AssistantFollowUps';
import type { ChangeItem } from './assistantParts';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
import AssistantWeekOverview from './AssistantWeekOverview';
import { useAssistantMenu } from './useAssistantMenu';
import { useTripMemory } from './useTripMemory';
import { useWeekDays } from './useWeekDays';
const TRIP = 9;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
const SETTINGS = useSettingsStore.getState();
const AUTH = useAuthStore.getState();
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useTripStore.setState(TRIPS, true);
useSettingsStore.setState(SETTINGS, true);
useAuthStore.setState(AUTH, true);
});
const view: AssistantView = { tripId: TRIP, variant: 'window' };
const inView = (node: ReactNode) => <AssistantViewContext.Provider value={view}>{node}</AssistantViewContext.Provider>;
function answer(parts: AssistantMessage['parts'], id = 'a1'): AssistantMessage {
return { id, role: 'assistant', status: 'complete', createdAt: '2026-10-21T08:00:00.000Z', parts };
}
function preview(
toolCallId: string,
dayId: number,
over: Partial<AssistantChangePreview> = {}
): AssistantChangePreview {
return {
toolCallId,
verbKey: 'addPlace',
subject: 'Place',
rows: [],
dayId,
placeIds: [],
undoable: true,
effect: { kind: 'add', dayId, fromDayId: null, placeId: null, assignmentId: null, place: null },
...over,
};
}
function waiting(toolCallId: string, data: AssistantChangePreview): AssistantMessage['parts'] {
return [
{ type: 'tool-call', toolCallId, toolName: 'create_place', args: {}, approval: { id: toolCallId } },
{ type: 'data-trek-change', data },
];
}
function seedChat(messages: AssistantMessage[], extra: Record<string, unknown> = {}) {
useAssistantStore.setState({
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages, ...extra } },
});
}
describe('the next questions', () => {
it('FE-AI-POWER-001: an answer offers what fits what it holds, changes only to someone who may make them', () => {
const ids = (parts: AssistantMessage['parts'], canEdit = true) =>
followUpsFor(answer(parts), canEdit).map((f) => f.id);
expect(ids([{ type: 'data-trek-places', data: { toolCallId: 's', places: [], more: 0 } }])).toContain('cheaper');
expect(ids([{ type: 'data-trek-change', data: preview('c', 1) }])).toContain('nextDay');
expect(ids([{ type: 'data-trek-change', data: preview('c', 1) }], false)).toEqual([]);
expect(ids([{ type: 'text', text: 'Hi' }])).toEqual(['more', 'next']);
expect(ids([])).toEqual([]);
});
it('FE-AI-POWER-002: under the last finished answer a tap sends the question; not while it runs', () => {
const send = vi.fn(async () => {});
const last = answer([{ type: 'text', text: 'Done.' }]);
seedChat([last]);
useAssistantStore.setState({ send });
const { rerender } = render(inView(<AssistantFollowUps message={last} />));
fireEvent.click(screen.getByRole('button', { name: 'Tell me more' }));
expect(send).toHaveBeenCalledWith(TRIP, 'Tell me more');
seedChat([last], { isRunning: true });
rerender(inView(<AssistantFollowUps message={last} />));
expect(screen.queryByRole('button', { name: 'Tell me more' })).toBeNull();
});
});
describe('the day list preview', () => {
it('FE-AI-POWER-003: a waiting add shows dashed on its day, a removal struck, and resting on it lights the change', () => {
useTripStore.setState({ places: [buildPlace({ id: 5, name: 'Louvre' })] });
const add = preview('c1', 3, {
effect: {
kind: 'add',
dayId: 3,
fromDayId: null,
placeId: null,
assignmentId: null,
place: { name: 'Pantheon', lat: 1, lng: 2, categoryId: null },
},
});
const remove = preview('c2', 3, {
effect: { kind: 'remove', dayId: 3, fromDayId: null, placeId: 5, assignmentId: 50, place: null },
});
seedChat([answer([...waiting('c1', add), ...waiting('c2', remove)])]);
render(<AssistantDayPreview tripId={TRIP} dayId={3} />);
expect(screen.getByText('Pantheon')).toBeInTheDocument();
expect(screen.getByText('Louvre')).toHaveClass('line-through');
fireEvent.mouseEnter(screen.getByText('Pantheon'));
expect(useAssistantStore.getState().mapLink?.hoveredId).toBe('c1');
});
it('FE-AI-POWER-004: nothing shows on another day, or once the change was decided', () => {
const add = preview('c1', 3, {
effect: {
kind: 'add',
dayId: 3,
fromDayId: null,
placeId: null,
assignmentId: null,
place: { name: 'Pantheon', lat: 1, lng: 2, categoryId: null },
},
});
seedChat([answer(waiting('c1', add))]);
const { container } = render(<AssistantDayPreview tripId={TRIP} dayId={4} />);
expect(container).toBeEmptyDOMElement();
});
});
describe('the week grid', () => {
const item = (id: string, dayId: number): ChangeItem => ({
toolCallId: id,
preview: preview(id, dayId),
applied: null,
state: 'pending',
});
it('FE-AI-POWER-005: from three days on the changes group by day, in trip order', () => {
useTripStore.setState({
trip: buildTrip({ id: TRIP }),
days: [
buildDay({ id: 1, date: '2026-11-02' }),
buildDay({ id: 2, date: '2026-11-03' }),
buildDay({ id: 3, date: '2026-11-04' }),
],
});
const wrapper = ({ children }: { children: ReactNode }) => inView(children);
const three = renderHook(() => useWeekDays([item('a', 3), item('b', 1), item('c', 2), item('d', 1)]), { wrapper });
expect(three.result.current.map((d) => [d.n, d.ids])).toEqual([
[1, ['b', 'd']],
[2, ['c']],
[3, ['a']],
]);
const two = renderHook(() => useWeekDays([item('a', 1), item('b', 2)]), { wrapper });
expect(two.result.current).toEqual([]);
});
it('FE-AI-POWER-006: a tap leaves a whole day out or takes it back in', () => {
const onToggleDay = vi.fn();
const days = [
{ id: 1, n: 1, weekday: 'Mon', ids: ['a', 'b'] },
{ id: 2, n: 2, weekday: 'Tue', ids: ['c'] },
];
render(<AssistantWeekOverview days={days} excluded={new Set(['c'])} disabled={false} onToggleDay={onToggleDay} />);
const [mon, tue] = screen.getAllByRole('button');
expect(mon).toHaveAttribute('aria-pressed', 'true');
expect(tue).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(mon);
expect(onToggleDay).toHaveBeenCalledWith(['a', 'b'], false);
fireEvent.click(tue);
expect(onToggleDay).toHaveBeenLastCalledWith(['c'], true);
});
});
describe('the context menu rows', () => {
it('FE-AI-POWER-007: none while the assistant cannot run; else they open the chat on the place and ask', () => {
const send = vi.fn(async () => {});
const openWith = vi.fn();
useAssistantStore.setState({ status: null, send, openWith });
const louvre = buildPlace({ id: 5, name: 'Louvre' });
const translated = ({ children }: { children: ReactNode }) => <TranslationProvider>{children}</TranslationProvider>;
const off = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
expect(off.result.current.place(louvre)).toEqual([]);
useAssistantStore.setState({ status: { available: true } as never });
const on = renderHook(() => useAssistantMenu(TRIP), { wrapper: translated });
const rows = on.result.current.place(louvre);
const alternative = rows.find((r) => 'label' in r && r.label === 'Find an alternative');
act(() => (alternative && 'onClick' in alternative ? alternative.onClick() : undefined));
expect(openWith).toHaveBeenCalledWith(TRIP, { placeIds: [5] });
expect(send).toHaveBeenCalledWith(TRIP, expect.stringContaining('Louvre'));
expect(on.result.current.day(3).length).toBeGreaterThan(1);
});
});
describe('the trip notes', () => {
it('FE-AI-POWER-008: notes are kept per trip, and saving empty ones drops the entry', async () => {
useAuthStore.setState({ user: buildUser({ id: 1 }) });
const updateSetting = vi.fn(async () => {});
useSettingsStore.setState({
settings: { ...SETTINGS.settings, assistant_memory: { '2': 'Other' } },
updateSetting,
});
const { result } = renderHook(() => useTripMemory(TRIP));
expect(result.current.has).toBe(false);
await act(async () => void (await result.current.save(' Vegetarian ')));
expect(updateSetting).toHaveBeenCalledWith('assistant_memory', { '2': 'Other', [TRIP]: 'Vegetarian' });
await act(async () => void (await result.current.save(' ')));
expect(updateSetting).toHaveBeenLastCalledWith('assistant_memory', { '2': 'Other' });
});
});
describe('the map a question carries', () => {
it('FE-AI-POWER-009: edges stay on the globe and are rounded to about ten metres', () => {
expect(mapViewOf({ south: 35.123456, west: -190, north: 35.2, east: 139.987654 })).toEqual({
south: 35.1235,
west: -180,
north: 35.2,
east: 139.9877,
});
});
it('FE-AI-POWER-010: a question goes out with the map of its own trip in its focus', async () => {
const send = vi.fn();
useAssistantStore.setState({ mapView: { tripId: TRIP, view: { south: 1, west: 2, north: 3, east: 4 } } });
seedChat([]);
// The request body is built inside the store; read it from the repo call.
const { assistantRepo } = await import('../../repo/assistantRepo');
const spy = vi.spyOn(assistantRepo, 'send').mockImplementation(async (_trip, body) => {
send(body);
throw new Error('stop');
});
await useAssistantStore.getState().send(TRIP, 'What is here?');
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ focus: { view: { south: 1, west: 2, north: 3, east: 4 } } })
);
spy.mockRestore();
});
});
@@ -0,0 +1,68 @@
import { ChevronDown, ShieldCheck } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../i18n';
import { useAssistantStore } from '../../store/assistantStore';
import { useToast } from '../shared/Toast';
import { providerLabel } from './providerCatalog';
/**
* Who reads along, said once. A user who connected their own provider agreed
* to this while connecting; on the admin's connection the chat says it here,
* above the composer, until the user has read it. A new provider set up by
* the admin asks again, because the answer is stored per provider and host.
*/
export default function AssistantPrivacyBanner() {
const { t } = useTranslation();
const toast = useToast();
const effective = useAssistantStore((s) => s.status?.effective ?? null);
const acked = useAssistantStore((s) => s.status?.privacyAcked ?? true);
const ackPrivacy = useAssistantStore((s) => s.ackPrivacy);
const [details, setDetails] = useState(false);
if (!effective || effective.source !== 'instance' || acked) return null;
const provider = providerLabel(effective.kind, effective.host);
const confirm = async () => {
try {
await ackPrivacy();
} catch {
toast.error(t('assistant.error.generic'));
}
};
return (
<div className="trek-assistant-enter rounded-lg border border-edge-faint bg-surface-secondary px-3 py-2.5 text-caption text-content-secondary">
<div className="flex items-start gap-2.5">
<ShieldCheck size={14} aria-hidden="true" className="mt-0.5 flex-shrink-0 text-content-muted" />
<p className="m-0 min-w-0 flex-1">{t('assistant.privacy.banner', { provider })}</p>
</div>
{details && (
<ul className="m-0 mt-2 list-disc space-y-1 ps-9">
<li>{t('assistant.privacy.shared')}</li>
<li>{t('assistant.privacy.notShared')}</li>
<li>{t('assistant.privacy.provider', { provider })}</li>
</ul>
)}
<div className="mt-2 flex items-center justify-end gap-1">
{!details && (
<button
type="button"
onClick={() => setDetails(true)}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-caption text-content-muted hover:bg-surface-hover hover:text-content"
>
{t('assistant.privacy.what')}
<ChevronDown size={12} aria-hidden="true" />
</button>
)}
<button
type="button"
onClick={() => void confirm()}
className="rounded-md px-2 py-1 text-caption font-medium text-accent-on hover:bg-surface-hover"
>
{t('assistant.privacy.ok')}
</button>
</div>
</div>
);
}
@@ -0,0 +1,52 @@
import { useTranslation } from '../../i18n';
import ToggleSwitch from '../Settings/ToggleSwitch';
import ConfirmDialog from '../shared/ConfirmDialog';
import { SHARE_MAX_CHARS } from './assistantShare';
import type { useAssistantShare } from './useAssistantShare';
/**
* The question before an answer goes to the trip chat: who will see it, the
* text exactly as it will arrive, and whether the applied changes go along.
* Nothing can be edited here; what goes out is what the preview shows. One
* dialog for the window and the phone, which lays it out as a sheet.
*/
export default function AssistantShareDialog({ share }: { share: ReturnType<typeof useAssistantShare> }) {
const { t } = useTranslation();
const { preview } = share;
return (
<ConfirmDialog
isOpen={share.previewing}
onClose={share.close}
onConfirm={() => void share.send()}
title={t('assistant.share.title')}
message={t('assistant.share.who')}
confirmLabel={t('assistant.share.send')}
danger={false}
>
{/* Spaced on its own: the phone's sheet sets the message flush against what follows. */}
<div className="flex flex-col gap-3 pt-2">
<p
dir="auto"
className="m-0 max-h-[40vh] overflow-y-auto whitespace-pre-wrap break-words rounded-xl border border-edge-faint bg-surface-secondary p-3 text-body text-content"
>
{preview.text}
</p>
{preview.hasChanges && (
<label className="flex items-center justify-between gap-3 text-body text-content">
<span>{t('assistant.share.includeChanges')}</span>
<ToggleSwitch
on={share.includeChanges}
onToggle={share.toggleChanges}
label={t('assistant.share.includeChanges')}
/>
</label>
)}
{preview.truncated && (
<p className="m-0 text-caption text-content-muted">
{t('assistant.share.truncated', { count: SHARE_MAX_CHARS })}
</p>
)}
</div>
</ConfirmDialog>
);
}
@@ -0,0 +1,182 @@
/**
* FE-AI-SURF-001 to FE-AI-SURF-006
*
* The chat on the real @assistant-ui runtime, fed from the assistant store:
* the empty chat with its suggestions, stored messages as the user sees
* them, sending from the composer, and the composer holding back while a
* change waits for a decision.
*/
import type { AiStatusResponse, AssistantMessage } from '@trek/shared';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import AssistantSurface from './AssistantSurface';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const AT = '2026-10-21T08:00:00.000Z';
const STATUS: AiStatusResponse = {
available: true,
policy: 'instance',
canConnectOwn: false,
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
own: null,
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
quota: null,
privacyAcked: true,
retention: { mode: 'days', days: 90 },
openrouterOAuth: false,
};
const send = vi.fn(async () => {});
function seed(messages: AssistantMessage[], status: AiStatusResponse = STATUS) {
useAssistantStore.setState({
status,
send,
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages } },
});
}
function renderChat() {
return render(<AssistantSurface tripId={TRIP} tripTitle="Kyoto" variant="window" />);
}
beforeAll(() => {
// jsdom lays nothing out and has no element scrolling; the viewport only needs the call to exist.
Element.prototype.scrollTo = vi.fn() as unknown as typeof Element.prototype.scrollTo;
});
beforeEach(() => send.mockClear());
afterEach(() => useAssistantStore.setState(INITIAL, true));
describe('AssistantSurface', () => {
it('FE-AI-SURF-001: the empty chat asks what to plan and offers questions that change nothing for an unknown trip', async () => {
seed([]);
renderChat();
expect(await screen.findByText('What are we planning?')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Make a packing list for this trip' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Which days are still empty?' }));
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Which days are still empty?'));
});
it('FE-AI-SURF-002: shows the question and the answer, with the steps folded away', async () => {
seed([
{ id: 'q1', role: 'user', status: 'complete', createdAt: AT, parts: [{ type: 'text', text: 'Plan Friday' }] },
{
id: 'a1',
role: 'assistant',
status: 'complete',
createdAt: AT,
parts: [
{ type: 'tool-call', toolCallId: 'r1', toolName: 'get_trip_summary', args: {}, result: { ok: true } },
{ type: 'text', text: 'Here is a **plan**.' },
],
},
]);
renderChat();
expect(await screen.findByText('Plan Friday')).toBeInTheDocument();
expect(screen.getByText('plan')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '1 step' })).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('What are we planning?')).toBeNull();
});
it('FE-AI-SURF-003: Enter sends what was typed, with the trip in the placeholder', async () => {
seed([]);
renderChat();
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
fireEvent.change(input, { target: { value: 'Where should we eat?' } });
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Where should we eat?');
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Where should we eat?'));
});
it('FE-AI-SURF-004: a change waiting for a decision holds the send button back', async () => {
seed([
{
id: 'a1',
role: 'assistant',
status: 'awaiting_approval',
createdAt: AT,
parts: [{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_todo', args: {}, approval: { id: 'w1' } }],
},
]);
renderChat();
const input = await screen.findByRole('textbox', { name: 'Ask anything about Kyoto' });
fireEvent.change(input, { target: { value: 'Something else' } });
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
});
it('FE-AI-SURF-005: on the admin connection the chat says once where messages go', async () => {
seed([], { ...STATUS, privacyAcked: false });
renderChat();
expect(
await screen.findByText('Your messages and the trip data the assistant reads go to OpenAI.')
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'What gets shared?' }));
expect(screen.getByText('Not your password, your files or your other trips.')).toBeInTheDocument();
});
it('FE-AI-SURF-006: an answer shows what its search found and the weather it looked up', async () => {
seed([
{
id: 'a1',
role: 'assistant',
status: 'complete',
createdAt: AT,
parts: [
{ type: 'tool-call', toolCallId: 's1', toolName: 'search_nearby_places', args: {}, result: { ok: true } },
{
type: 'data-trek-places',
data: {
toolCallId: 's1',
more: 0,
places: [
{
ref: 's1:0',
name: 'Nishiki Market',
address: null,
lat: 35.005,
lng: 135.765,
kind: 'food',
rating: null,
openNow: null,
googlePlaceId: null,
osmId: null,
photoKey: null,
inTripPlaceId: null,
},
],
},
},
{
type: 'data-trek-weather',
data: {
days: [
{
dayId: null,
date: '2026-11-02',
main: 'Rain',
tempMax: 14,
tempMin: 9,
rainPct: 70,
kind: 'forecast',
},
],
},
},
{ type: 'text', text: 'Rain on Monday, so the market is a good pick.' },
],
},
]);
renderChat();
expect(await screen.findByRole('region', { name: 'Places found' })).toBeInTheDocument();
expect(screen.getByText('Nishiki Market')).toBeInTheDocument();
expect(screen.getByRole('list', { name: 'Weather for 1 day' })).toBeInTheDocument();
expect(screen.getByText('14°')).toBeInTheDocument();
});
});
@@ -0,0 +1,49 @@
import './assistant.css';
import { AssistantRuntimeProvider } from '@assistant-ui/react';
import { useMemo } from 'react';
import AssistantThread from './AssistantThread';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
import { useAssistantRuntime } from './useAssistantRuntime';
export interface AssistantSurfaceProps extends AssistantView {
/** Named in the composer's placeholder. */
tripTitle: string;
}
/**
* The chat for one trip, the same on the desktop window and the phone screen.
* Its own chunk: this is the only module tree that pulls in @assistant-ui, so
* nobody downloads the chat runtime before opening the assistant.
*/
export default function AssistantSurface({
tripId,
tripTitle,
variant,
density,
onShowOnMap,
onFocusDay,
onRevealMap,
}: AssistantSurfaceProps) {
const runtime = useAssistantRuntime(tripId);
// The phone is always compact; a window is what its width says.
const view = useMemo(
() => ({
tripId,
variant,
density: variant === 'fullscreen' ? ('compact' as const) : (density ?? 'roomy'),
onShowOnMap,
onFocusDay,
onRevealMap,
}),
[tripId, variant, density, onShowOnMap, onFocusDay, onRevealMap]
);
return (
<AssistantRuntimeProvider runtime={runtime}>
<AssistantViewContext.Provider value={view}>
<AssistantThread tripTitle={tripTitle} />
</AssistantViewContext.Provider>
</AssistantRuntimeProvider>
);
}
@@ -0,0 +1,34 @@
import { Suspense } from 'react';
import { lazyWithRetry } from '../../utils/lazyWithRetry';
import ErrorBoundary from '../shared/ErrorBoundary';
import { Spinner } from '../shared/Spinner';
import type { AssistantSurfaceProps } from './AssistantSurface';
/**
* The chat, loaded when it is first shown: the window and the phone screen
* mount this, and the chat runtime arrives in one chunk of its own (kept out
* of the offline precache, the assistant needs the network anyway).
*/
const AssistantSurface = lazyWithRetry(() => import('./AssistantSurface'));
export default function AssistantSurfaceLazy(props: AssistantSurfaceProps) {
const waiting = (
<div className="flex flex-1 items-center justify-center">
<Spinner className="h-5 w-5 border-2 border-edge border-t-[color:var(--text-primary)]" />
</div>
);
return (
// Boundary outside the Suspense, as in VideoPlayerLazy: a rejected chunk flies past Suspense.
<ErrorBoundary
boundaryId="assistant"
level="panel"
variant={props.variant === 'fullscreen' ? 'mobile' : 'desktop'}
resetKeys={[props.tripId]}
>
<Suspense fallback={waiting}>
<AssistantSurface key={props.tripId} {...props} />
</Suspense>
</ErrorBoundary>
);
}
@@ -0,0 +1,91 @@
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import AssistantComposer from './AssistantComposer';
import AssistantError from './AssistantError';
import AssistantMessage from './AssistantMessage';
import AssistantPrivacyBanner from './AssistantPrivacyBanner';
import AssistantWelcome, { AssistantSuggestions } from './AssistantWelcome';
import { useAssistantView } from './assistantViewContext';
import { useAssistantAttach } from './useAssistantAttach';
import { useFollowSelectedDay } from './useAssistantFocus';
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
import { useAssistantSuggestions } from './useAssistantSuggestions';
/**
* The chat itself: the messages, and under them the line that says what went
* wrong, the privacy note and the composer, held at the bottom while the
* messages scroll. A new question moves up to the top of the view, so its
* answer reads from its first line instead of chasing the newest one. A
* booking file or a place of the trip can be dropped anywhere on it.
*/
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
useFollowSelectedDay(tripId);
const phone = variant === 'fullscreen';
const attach = useAssistantAttach(tripId);
const placeDrop = useAssistantPlaceDrop(tripId);
const DropIcon = placeDrop.dragging ? MapPin : FileUp;
// `@` reaches only the trip the planner shows; for another one the hint would promise nothing.
const mentionable = useTripStore((s) => s.trip?.id === tripId);
return (
<ThreadPrimitive.Root
className="relative flex min-h-0 flex-1 flex-col"
{...joinDropZones(attach.dropZone, placeDrop.dropZone)}
>
{(attach.dragging || placeDrop.dragging) && (
<div className="pointer-events-none absolute inset-2 z-20 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-edge bg-surface-elevated text-center backdrop-blur-sm">
<DropIcon size={22} aria-hidden="true" className="text-content-secondary" />
<span className="max-w-[16rem] text-body font-medium text-content">
{t(placeDrop.dragging ? 'assistant.attach.dropPlace' : 'assistant.attach.drop')}
</span>
</div>
)}
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
<AuiIf condition={(s) => s.thread.isEmpty}>
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
{mentionable && (
<p className="m-0 pb-1 text-center text-caption text-content-faint">{t('assistant.mention.hint')}</p>
)}
</AuiIf>
<div role="log" aria-label={t('assistant.window.log')} aria-busy={isRunning} className="pt-2">
<ThreadPrimitive.Messages>
{({ message }) => {
const stored = byId.get(message.id);
return stored ? <AssistantMessage message={stored} /> : null;
}}
</ThreadPrimitive.Messages>
</div>
</ThreadPrimitive.Viewport>
{/* Outside the scroller, so it needs no backdrop of its own: on the glass window a second
surface behind the composer showed as a box. */}
<div className="relative flex flex-shrink-0 flex-col gap-2 px-4 pb-3 pt-2">
<ThreadPrimitive.ScrollToBottom
aria-label={t('assistant.thread.scrollDown')}
className="absolute -top-10 start-1/2 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-edge-faint bg-surface-card text-content-secondary shadow-dropdown hover:text-content disabled:hidden rtl:translate-x-1/2"
>
<ArrowDown size={15} aria-hidden="true" />
</ThreadPrimitive.ScrollToBottom>
{phone && (
<AuiIf condition={(s) => s.thread.isEmpty}>
<AssistantSuggestions suggestions={suggestions} layout="row" />
</AuiIf>
)}
<AssistantError tripId={tripId} />
<AssistantPrivacyBanner />
<AssistantComposer tripId={tripId} tripTitle={tripTitle} />
</div>
</ThreadPrimitive.Root>
);
}
@@ -0,0 +1,47 @@
import { ChevronRight } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../i18n';
import AssistantLiveStep from './AssistantLiveStep';
import { toolStepKey } from './assistantModel';
import type { ToolStep } from './assistantParts';
/**
* What the assistant looked up on the way to its answer. While a step runs it
* says what it does ("Reading your plan", "Searching near Trastevere") and
* shimmers; once the run is done the steps fold into one quiet line that
* opens to the list. Tool names never show: they are the model's words, not
* the user's.
*/
export default function AssistantToolSteps({ steps }: { steps: ToolStep[] }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const running = steps.filter((s) => s.running).pop();
if (running) return <AssistantLiveStep toolCallId={running.toolCallId} toolName={running.toolName} />;
return (
<div className="text-caption text-content-muted">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="-ms-1 inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-caption text-content-muted hover:bg-surface-hover hover:text-content-secondary"
>
<ChevronRight
size={13}
aria-hidden="true"
className={`transition-transform duration-150 rtl:-scale-x-100 ${open ? 'rotate-90' : ''}`}
/>
{t('assistant.step.collapsed', { count: steps.length })}
</button>
{open && (
<ul className="m-0 mt-1 list-none space-y-0.5 border-s border-edge-faint ps-3">
{steps.map((step) => (
<li key={step.toolCallId}>{t(toolStepKey(step.toolName))}</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,61 @@
import { ChevronRight, Luggage } from 'lucide-react';
import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState';
import { Spinner } from '../shared/Spinner';
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
/**
* Outside a trip the assistant first asks which one: the chat, its history
* and its changes all belong to one trip. The running and the next trip lead.
*/
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
const { t } = useTranslation();
const trips = useAssistantTripPicker();
if (trips === null) {
return (
<div className="flex flex-1 items-center justify-center">
<Spinner className="h-5 w-5 border-2 border-edge border-t-[color:var(--text-primary)]" />
</div>
);
}
if (trips.length === 0) {
return (
<div className="flex flex-1 items-center justify-center px-6">
<EmptyState scene="idle" size={72} compact surface="var(--bg-elevated)" title={t('assistant.picker.empty')} />
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
{trips.map((trip) => (
<li key={trip.id}>
<button
type="button"
onClick={() => onPick(trip)}
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
>
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-surface-secondary text-content-muted">
<Luggage size={15} aria-hidden="true" />
</span>
<bdi className="min-w-0 flex-1 truncate text-body text-content">{trip.title}</bdi>
{trip.badge && (
<span className="flex-shrink-0 rounded-full bg-accent-subtle px-2 py-px text-caption text-accent-on">
{t(trip.badge === 'now' ? 'assistant.picker.now' : 'assistant.picker.next')}
</span>
)}
<ChevronRight
size={14}
aria-hidden="true"
className="flex-shrink-0 text-content-faint rtl:-scale-x-100"
/>
</button>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,92 @@
/**
* FE-AI-WEATHER-001 to FE-AI-WEATHER-005
*
* The weather row of an answer: temperatures in the user's unit, a drop only
* where rain is likely, the day in focus framed, and a trip day that selects
* itself in the planner.
*/
import type { AssistantWeatherDay } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import AssistantWeatherStrip from './AssistantWeatherStrip';
import { inUnit } from './assistantModel';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const SETTINGS = useSettingsStore.getState();
function day(date: string, extra: Partial<AssistantWeatherDay> = {}): AssistantWeatherDay {
return { dayId: null, date, main: 'Clear', tempMax: 21, tempMin: 12, rainPct: null, kind: 'forecast', ...extra };
}
function renderStrip(days: AssistantWeatherDay[], view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
return render(
<AssistantViewContext.Provider value={value}>
<AssistantWeatherStrip days={days} />
</AssistantViewContext.Provider>
);
}
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useSettingsStore.setState(SETTINGS, true);
});
describe('AssistantWeatherStrip', () => {
it('FE-AI-WEATHER-001: shows °C in the user unit, without a negative zero', () => {
expect(inUnit(21, false)).toBe(21);
expect(inUnit(21, true)).toBe(70);
expect(inUnit(-0.4, false)).toBe(0);
useSettingsStore.setState({ settings: { ...SETTINGS.settings, temperature_unit: 'fahrenheit' } });
renderStrip([day('2026-11-02')]);
expect(screen.getByText('70°')).toBeInTheDocument();
expect(screen.getByText('54°')).toBeInTheDocument();
});
it('FE-AI-WEATHER-002: a drop shows from 30 % rain on, and a climate figure reads as approximate', () => {
renderStrip([
day('2026-11-02', { rainPct: 29 }),
day('2026-11-03', { rainPct: 60 }),
day('2026-11-04', { kind: 'climate', tempMax: 18 }),
]);
expect(screen.queryByText('29%')).toBeNull();
expect(screen.getByText('60%')).toBeInTheDocument();
expect(screen.getByText('≈18°')).toBeInTheDocument();
expect(screen.getByRole('list', { name: 'Weather for 3 days' })).toBeInTheDocument();
});
it('FE-AI-WEATHER-003: a trip day selects itself in the planner, and the day in focus is marked', () => {
const onFocusDay = vi.fn();
useAssistantStore.setState({
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), focus: { dayId: 31 } } },
});
renderStrip([day('2026-11-02', { dayId: 31 }), day('2026-11-03', { dayId: 32 })], { onFocusDay });
const [first, second] = screen.getAllByRole('button');
expect(first).toHaveAttribute('aria-current', 'true');
expect(second).not.toHaveAttribute('aria-current');
if (second) fireEvent.click(second);
expect(onFocusDay).toHaveBeenCalledWith(32);
});
it('FE-AI-WEATHER-004: without a planner or a trip day a chip is only text', () => {
renderStrip([day('2026-11-02', { dayId: 31 })]);
expect(screen.queryByRole('button')).toBeNull();
renderStrip([day('2026-11-03')], { onFocusDay: vi.fn() });
expect(screen.queryByRole('button')).toBeNull();
});
it('FE-AI-WEATHER-005: a chip grows with three-digit or approximate figures instead of spilling over', () => {
useSettingsStore.setState({ settings: { ...SETTINGS.settings, temperature_unit: 'fahrenheit' } });
renderStrip([day('2026-07-02', { kind: 'climate', tempMax: 39, tempMin: 29 })]);
expect(screen.getByText('≈102°')).toBeInTheDocument();
const chip = screen.getByRole('listitem');
expect(chip.className).toContain('min-w-14');
expect(chip.className.split(' ')).not.toContain('w-14');
});
});
@@ -0,0 +1,119 @@
import type { AssistantWeatherDay } from '@trek/shared';
import { Droplet } from 'lucide-react';
import { createElement } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import { weatherIconFor } from '../Weather/weatherIcons';
import { Tooltip } from '../shared/Tooltip';
import { inUnit } from './assistantModel';
import { useAssistantView } from './assistantViewContext';
/** Rain this likely or more gets its drop. */
const RAIN_SHOWN_PCT = 30;
/** The weekday and the day of the month of a `YYYY-MM-DD` date, read as a calendar date wherever the user is. */
function dateParts(iso: string, locale: string): { weekday: string; day: string } {
const [y, m, d] = iso.split('-').map(Number);
const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1);
return { weekday: date.toLocaleDateString(locale, { weekday: 'short' }), day: String(date.getDate()) };
}
function WeatherChip({
day,
fahrenheit,
focused,
onPick,
}: {
day: AssistantWeatherDay;
fahrenheit: boolean;
focused: boolean;
onPick: ((dayId: number) => void) | null;
}) {
const { t, locale } = useTranslation();
const icon = weatherIconFor(day.main);
const { weekday, day: dayOfMonth } = dateParts(day.date, locale);
// A climate figure is an average for the date, not a forecast; it says so with "≈".
const approx = day.kind === 'climate' ? '≈' : '';
const max = `${approx}${inUnit(day.tempMax, fahrenheit)}°`;
const min = day.tempMin === null ? null : `${inUnit(day.tempMin, fahrenheit)}°`;
const rainy = day.rainPct !== null && day.rainPct >= RAIN_SHOWN_PCT;
const dayId = day.dayId;
const body = (
<>
<span className="text-caption text-content-muted">
{weekday} {dayOfMonth}
</span>
{createElement(icon, { size: 16, strokeWidth: 1.8, 'aria-hidden': true, className: 'text-content-secondary' })}
<span className="flex items-baseline gap-1 tabular-nums">
<span className="text-body font-semibold text-content">{max}</span>
{min && <span className="text-caption text-content-faint">{min}</span>}
</span>
{rainy && (
<Tooltip label={t('assistant.weather.rain', { pct: day.rainPct ?? 0 })} placement="top">
<span className="inline-flex items-center gap-0.5 text-caption tabular-nums text-content-muted">
<Droplet size={10} aria-hidden="true" />
{day.rainPct}%
</span>
</Tooltip>
)}
</>
);
const look = `flex min-w-14 flex-shrink-0 snap-start flex-col items-center gap-0.5 rounded-lg border px-1 py-1.5 ${
focused ? 'border-edge bg-surface-card' : 'border-transparent bg-surface-secondary'
}`;
const label = `${weekday} ${dayOfMonth}, ${max}${min ? ` / ${min}` : ''}`;
if (onPick && dayId !== null) {
return (
<li className="flex">
<button
type="button"
onClick={() => onPick(dayId)}
aria-label={label}
aria-current={focused || undefined}
className={`${look} transition-colors hover:bg-surface-hover`}
>
{body}
</button>
</li>
);
}
return (
<li aria-label={label} className={look}>
{body}
</li>
);
}
/**
* The weather the assistant looked up, one small chip per day: weekday and
* date, the sky, the high in full and the low in quiet, a drop from 30 % rain
* on. The day in focus is framed; a chip with a trip day selects that day in
* the planner. More than a week scrolls sideways.
*/
export default function AssistantWeatherStrip({ days }: { days: AssistantWeatherDay[] }) {
const { t } = useTranslation();
const { tripId, onFocusDay } = useAssistantView();
const fahrenheit = useSettingsStore((s) => s.settings.temperature_unit) === 'fahrenheit';
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
return (
<ul
aria-label={t('assistant.weather.strip', { count: days.length })}
className="trek-assistant-strip -mx-4 flex snap-x scroll-px-4 gap-1.5 overflow-x-auto px-4"
>
{days.map((day) => (
<WeatherChip
key={day.date}
day={day}
fahrenheit={fahrenheit}
focused={day.dayId !== null && day.dayId === focusDayId}
onPick={onFocusDay ?? null}
/>
))}
</ul>
);
}
@@ -0,0 +1,69 @@
import { useTranslation } from '../../i18n';
import type { WeekDay } from './useWeekDays';
/**
* A plan over several days as one row of small tiles: a day per tile, its
* number of changes in the corner, taken in or left out as a whole with one
* tap. The single changes
* stay below it, folded away until asked for.
*/
export default function AssistantWeekOverview({
days,
excluded,
disabled,
onToggleDay,
}: {
days: readonly WeekDay[];
excluded: ReadonlySet<string>;
disabled: boolean;
onToggleDay: (ids: readonly string[], include: boolean) => void;
}) {
const { t } = useTranslation();
return (
<div
role="group"
aria-label={t('assistant.week.title')}
className="mb-2 grid gap-2 pt-1"
style={{ gridTemplateColumns: `repeat(${Math.min(days.length, 7)}, minmax(0, 1fr))` }}
>
{days.map((day, i) => {
const on = day.ids.filter((id) => !excluded.has(id)).length;
const taken = on > 0;
const count = taken ? on : day.ids.length;
const name = `${day.weekday ?? t('assistant.week.day')} ${day.n}`;
return (
<button
key={day.id}
type="button"
disabled={disabled}
aria-pressed={taken}
aria-label={`${name}, ${t('assistant.week.changes', { count })}`}
title={t('assistant.week.changes', { count })}
onClick={() => onToggleDay(day.ids, !taken)}
className={`trek-assistant-enter relative flex min-w-0 flex-col items-center rounded-lg py-1.5 outline-none transition-all focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-default ${
taken
? 'bg-accent text-accent-text shadow-sm hover:-translate-y-px hover:shadow-md'
: 'bg-surface-secondary text-content-faint hover:text-content-muted'
}`}
style={{ animationDelay: `${i * 40}ms` }}
>
<span className={`text-caption leading-tight ${taken ? 'opacity-75' : ''}`}>
{day.weekday ?? t('assistant.week.day')}
</span>
<span className={`text-body font-semibold tabular-nums leading-tight ${taken ? '' : 'line-through'}`}>
{day.n}
</span>
<span
aria-hidden="true"
className={`absolute -end-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full px-1 text-caption font-semibold tabular-nums leading-none ring-2 ring-surface-card ${
taken ? 'bg-surface-card text-content' : 'bg-surface-tertiary text-content-faint'
}`}
>
{count}
</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,79 @@
import { ThreadPrimitive } from '@assistant-ui/react';
import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState';
import AssistantHints, { MAX_VISIBLE_HINTS } from './AssistantHints';
import { useAssistantView } from './assistantViewContext';
import { useAssistantHints } from './useAssistantHints';
import type { ReadySuggestion } from './useAssistantSuggestions';
/** Suggestions left under a full list of hints, so both fit the smallest window. */
const SUGGESTIONS_UNDER_HINTS = 2;
/**
* The empty chat: the mascot, one question, and a few things to ask that fit
* where the trip stands. A suggestion sends itself; there is nothing to edit
* first. On the phone the suggestions sit by the composer instead, where the
* thumb is, so this shows them only when `withSuggestions` asks for it.
* Between the two sit the plan hints, when the trip has any.
*/
export default function AssistantWelcome({
suggestions,
canEdit,
withSuggestions,
}: {
suggestions: ReadySuggestion[];
canEdit: boolean;
withSuggestions: boolean;
}) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
const hints = useAssistantHints(tripId, { withStays: true });
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={t('assistant.empty.title')} />
{!canEdit && (
<p className="m-0 max-w-xs text-center text-caption text-content-muted">{t('assistant.empty.readOnly')}</p>
)}
<AssistantHints view={hints} phone={variant === 'fullscreen'} />
{withSuggestions && offered.length > 0 && <AssistantSuggestions suggestions={offered} layout="stack" />}
</div>
);
}
export function AssistantSuggestions({
suggestions,
layout,
}: {
suggestions: ReadySuggestion[];
/** 'stack' under the mascot, 'row' as a strip of chips over the composer. */
layout: 'stack' | 'row';
}) {
return (
<div
className={
layout === 'stack'
? 'trek-stagger flex w-full max-w-sm flex-col gap-1.5'
: 'trek-stagger -mx-1 flex gap-2 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]'
}
>
{suggestions.map((s) => (
<ThreadPrimitive.Suggestion
key={s.id}
prompt={s.prompt}
send
dir="auto"
className={
layout === 'stack'
? 'rounded-xl border border-edge-faint bg-surface-card px-3.5 py-2.5 text-start text-body text-content transition-colors hover:border-edge hover:bg-surface-hover'
: 'min-h-[44px] flex-shrink-0 whitespace-nowrap rounded-full border border-edge-faint bg-surface-card px-4 text-body text-content'
}
>
{s.prompt}
</ThreadPrimitive.Suggestion>
))}
</div>
);
}
@@ -0,0 +1,188 @@
/**
* FE-AI-WIN-001 to FE-AI-WIN-010
*
* The desktop window around the chat: when it shows at all, the button it
* opens from, the trip picker outside a trip, closing that keeps the chat, and
* Escape, which stops an answer before it closes anything (and leaves the
* header's menu to itself), and where the focus goes on the way. The geometry is
* useDragResize's and has its own tests; here it is a fixed rect.
*/
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { tripRepo } from '../../repo/tripRepo';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import type { Trip } from '../../types';
import AssistantWindow from './AssistantWindow';
vi.mock('../../hooks/useDragResize', () => ({
useDragResize: () => ({
rect: { x: 600, y: 120, width: 400, height: 600 },
active: false,
handleProps: {},
edgeProps: () => ({}),
onKeyDown: () => {},
}),
}));
vi.mock('./AssistantSurfaceLazy', () => ({
default: ({
tripId,
tripTitle,
density,
onFocusDay,
}: {
tripId: number;
tripTitle: string;
density?: string;
onFocusDay?: (dayId: number) => void;
}) => (
<div>
<p>
chat {tripId} {tripTitle}
</p>
<output data-testid="density">{density}</output>
{onFocusDay && (
<button type="button" onClick={() => onFocusDay(12)}>
pick day
</button>
)}
<textarea aria-label="composer" />
</div>
),
}));
const TRIP = { id: 4, title: 'Kyoto' };
const INITIAL = useAssistantStore.getState();
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
const loadStatus = vi.fn(async () => {});
const resume = vi.fn(async () => {});
const cancel = vi.fn();
function seed(window: { open: boolean; minimized: boolean }, isRunning = false) {
// A chat the test before unmounted stops itself on the way out; that call is not this test's.
cancel.mockClear();
useAssistantStore.setState({
status: STATUS,
window,
loadStatus,
resume,
cancel,
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning } },
});
}
beforeEach(() => {
vi.spyOn(tripRepo, 'list').mockResolvedValue({
trips: [{ id: 9, title: 'Lisbon', start_date: '2026-10-01', end_date: '2026-12-01' } as Trip],
archivedTrips: [],
});
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('AssistantWindow', () => {
it('FE-AI-WIN-001: shows nothing at all without a connection, but asks for the status', () => {
seed({ open: true, minimized: false });
useAssistantStore.setState({ status: { ...STATUS, available: false } });
const { container } = render(<AssistantWindow trip={TRIP} />);
expect(container).toBeEmptyDOMElement();
expect(loadStatus).toHaveBeenCalled();
});
it('FE-AI-WIN-002: closed, the planner shows the round button, and it opens the chat on this trip', () => {
seed({ open: false, minimized: false });
render(<AssistantWindow trip={TRIP} />);
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
expect(screen.getByRole('dialog', { name: 'Assistant' })).toHaveTextContent('chat 4 Kyoto');
expect(resume).toHaveBeenCalledWith(TRIP.id);
});
it('FE-AI-WIN-003: closed outside a trip, nothing floats', () => {
seed({ open: false, minimized: false });
const { container } = render(<AssistantWindow trip={null} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={null} />);
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
});
it('FE-AI-WIN-005: closed while writing, the chat stays mounted and the round button breathes until it is opened', () => {
seed({ open: true, minimized: true }, true);
render(<AssistantWindow trip={TRIP} />);
expect(screen.getByText('chat 4 Kyoto')).not.toBeVisible();
const button = screen.getByRole('button', { name: 'Assistant' });
expect(button).toHaveAttribute('aria-busy', 'true');
fireEvent.click(button);
expect(useAssistantStore.getState().window.minimized).toBe(false);
expect(cancel).not.toHaveBeenCalled();
});
it('FE-AI-WIN-006: Escape stops a running answer first and only then closes', () => {
seed({ open: true, minimized: false }, true);
render(<AssistantWindow trip={TRIP} />);
const dialog = screen.getByRole('dialog', { name: 'Assistant' });
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(cancel).toHaveBeenCalledWith(TRIP.id);
expect(useAssistantStore.getState().window.open).toBe(true);
act(() =>
useAssistantStore.setState({
byTrip: { [TRIP.id]: { ...chatOf(useAssistantStore.getState(), TRIP.id), isRunning: false } },
})
);
fireEvent.keyDown(dialog, { key: 'Escape' });
expect(useAssistantStore.getState().window.open).toBe(false);
});
it('FE-AI-WIN-007: the head has one close button, which hides the window and keeps the chat', () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
expect(screen.queryByRole('button', { name: 'Minimize' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(useAssistantStore.getState().window.minimized).toBe(true);
expect(screen.getByText('chat 4 Kyoto')).not.toBeVisible();
});
it('FE-AI-WIN-008: closing hands the focus to the round button, opening hands it back to the composer', async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
const button = await screen.findByRole('button', { name: 'Assistant' });
expect(button).toHaveFocus();
fireEvent.click(button);
await waitFor(() => expect(screen.getByLabelText('composer')).toHaveFocus());
});
it("FE-AI-WIN-009: Escape in the header's menu closes the menu, not the window", async () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
fireEvent.click(screen.getByRole('button', { name: 'More options' }));
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
expect(useAssistantStore.getState().window.open).toBe(true);
});
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
seed({ open: true, minimized: false });
const onFocusDay = vi.fn();
const { unmount } = render(<AssistantWindow trip={TRIP} onFocusDay={onFocusDay} />);
expect(useAssistantStore.getState().windowRect).toEqual({ x: 600, y: 120, width: 400, height: 600 });
expect(screen.getByTestId('density')).toHaveTextContent('compact');
fireEvent.click(screen.getByRole('button', { name: 'pick day' }));
expect(onFocusDay).toHaveBeenCalledWith(12);
unmount();
expect(useAssistantStore.getState().windowRect).toBeNull();
});
});
@@ -0,0 +1,297 @@
import './assistant.css';
import type { AssistantApplied } from '@trek/shared';
import { Sparkles, X } from 'lucide-react';
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { type ResizeEdge, type WindowRect, type WindowSize, useDragResize } from '../../hooks/useDragResize';
import { useTranslation } from '../../i18n';
import { useAssistantStore } from '../../store/assistantStore';
import AssistantHeader from './AssistantHeader';
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
import AssistantSurfaceLazy from './AssistantSurfaceLazy';
import AssistantTripPicker from './AssistantTripPicker';
import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantThread } from './useAssistantThread';
/**
* The assistant on the desktop: a glass window floating over the app, moved
* by its head, resized from any edge or corner, kept inside the screen. It
* remembers where it was. A double click on the head switches between the
* compact window and a large one; let go near a corner and it settles there;
* Alt+arrows move it and Alt+Shift+arrows resize it from the keyboard.
*
* The head's close button only hides it: the conversation goes on behind it,
* and the planner shows the round button it opens from again, breathing while
* an answer is still being written. Escape stops an answer first, then
* closes, and the focus goes back to where it was.
*
* Mounted once for the whole app; it shows nothing until the assistant has a
* connection this user can use.
*/
interface WindowTrip {
id: number;
title: string;
}
interface AssistantWindowProps {
/** The trip on screen; null outside a trip, where the window asks which one first. */
trip: WindowTrip | null;
/** Shows an applied change in the planner on screen. */
onShowOnMap?: (applied: AssistantApplied) => void;
/** Selects a day in the planner on screen. */
onFocusDay?: (dayId: number) => void;
}
/** Narrower than this, the chat lays its cards out compact. */
const ROOMY_WIDTH = 480;
/** Where the window's place and size are kept between visits. */
const WINDOW_STORAGE = 'trek.assistantWindow';
const MIN_SIZE: WindowSize = { width: 340, height: 420 };
const DEFAULT_SIZE: WindowSize = { width: 400, height: 600 };
/** Never closer to the screen's edge than this. */
const MARGIN = 16;
/** The default distance from the edges, and how near a corner a released window snaps into it. */
const GAP = 24;
const expandedSize = (viewport: WindowSize): WindowSize => ({
width: Math.min(720, viewport.width - 2 * MARGIN),
height: Math.round(viewport.height * 0.8),
});
const EDGES: { edge: ResizeEdge; className: string; cursor: 'ns' | 'ew' | 'corner-start' | 'corner-end' }[] = [
{ edge: 'n', className: 'inset-x-3 -top-1 h-2', cursor: 'ns' },
{ edge: 's', className: 'inset-x-3 -bottom-1 h-2', cursor: 'ns' },
{ edge: 'start', className: 'inset-y-3 -start-1 w-2', cursor: 'ew' },
{ edge: 'end', className: 'inset-y-3 -end-1 w-2', cursor: 'ew' },
{ edge: 'n-start', className: '-start-1 -top-1 h-4 w-4', cursor: 'corner-start' },
{ edge: 's-end', className: '-bottom-1 -end-1 h-4 w-4', cursor: 'corner-start' },
{ edge: 'n-end', className: '-end-1 -top-1 h-4 w-4', cursor: 'corner-end' },
{ edge: 's-start', className: '-bottom-1 -start-1 h-4 w-4', cursor: 'corner-end' },
];
function edgeCursor(kind: (typeof EDGES)[number]['cursor'], rtl: boolean): string {
if (kind === 'ns') return 'ns-resize';
if (kind === 'ew') return 'ew-resize';
// The top-start corner is the top left in a left-to-right page and the top right in Arabic.
return (kind === 'corner-start') !== rtl ? 'nwse-resize' : 'nesw-resize';
}
/** The fixed navbar's height (`--nav-h`, a calc() and 0 where there is none), measured. */
function navbarHeight(): number {
if (typeof document === 'undefined') return 0;
const probe = document.createElement('div');
probe.style.cssText = 'position:fixed;visibility:hidden;height:var(--nav-h, 0px)';
document.body.appendChild(probe);
const height = probe.offsetHeight;
probe.remove();
return height;
}
/** Where the floating button's middle sits inside the window, so the window grows out of it. */
function originFrom(rect: WindowRect, rtl: boolean): string {
const fromStart = window.innerWidth - LAUNCHER_INSET - LAUNCHER_SIZE / 2 - rect.x;
const x = rtl ? rect.width - fromStart : fromStart;
const y = window.innerHeight - LAUNCHER_INSET - LAUNCHER_SIZE / 2 - rect.y;
return `${Math.round(x)}px ${Math.round(y)}px`;
}
function ChatWindow({
trip,
hidden,
onShowOnMap,
onFocusDay,
}: {
trip: WindowTrip;
hidden: boolean;
onShowOnMap?: (applied: AssistantApplied) => void;
onFocusDay?: (dayId: number) => void;
}) {
const { t } = useTranslation();
const thread = useAssistantThread(trip.id);
const close = useAssistantStore((s) => s.close);
const setMinimized = useAssistantStore((s) => s.setMinimized);
const setWindowRect = useAssistantStore((s) => s.setWindowRect);
// The navbar sits on the same layer and later in the page: the head never goes under it.
const [topInset] = useState(navbarHeight);
const frame = useDragResize({
storageKey: WINDOW_STORAGE,
minSize: MIN_SIZE,
defaultSize: DEFAULT_SIZE,
expandedSize,
margin: MARGIN,
gap: GAP,
topInset,
});
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
const [origin] = useState(() => originFrom(frame.rect, rtl));
const returnFocus = useRef<Element | null>(typeof document !== 'undefined' ? document.activeElement : null);
const sectionRef = useRef<HTMLElement>(null);
const wasHidden = useRef(hidden);
const { x, y, width, height } = frame.rect;
// The map frames its view around the window: it learns where the window
// settled after a move or a resize, not every step of the drag.
useEffect(() => {
if (hidden) setWindowRect(null);
else if (!frame.active) setWindowRect({ x, y, width, height });
}, [hidden, frame.active, x, y, width, height, setWindowRect]);
useEffect(() => () => setWindowRect(null), [setWindowRect]);
// Back to whatever had the focus before the window opened.
useEffect(() => {
const target = returnFocus.current;
return () => {
if (target instanceof HTMLElement && target.isConnected) target.focus();
};
}, []);
// Restored from the pill: back into the composer, where the user left off.
useEffect(() => {
if (wasHidden.current && !hidden) sectionRef.current?.querySelector('textarea')?.focus();
wasHidden.current = hidden;
}, [hidden]);
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
// Keys from a portal (the header's menu) bubble here through React; they are not the window's.
if (!e.currentTarget.contains(e.target as Node)) return;
frame.onKeyDown(e);
if (e.key !== 'Escape' || e.defaultPrevented) return;
e.preventDefault();
if (thread.isRunning) thread.stop();
else close();
};
const style: CSSProperties = {
insetInlineStart: frame.rect.x,
top: frame.rect.y,
width: frame.rect.width,
height: frame.rect.height,
transformOrigin: origin,
};
return (
<section
ref={sectionRef}
role="dialog"
aria-label={t('assistant.window.title')}
// Hidden, not unmounted, while minimized: the answer being written keeps coming in.
hidden={hidden}
onKeyDown={onKeyDown}
style={style}
className={`trek-assistant-pop fixed z-[var(--z-nav)] ${hidden ? 'hidden' : 'flex'} flex-col overflow-visible rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150 ${
frame.active ? 'select-none' : 'trek-assistant-settle'
}`}
>
{EDGES.map(({ edge, className, cursor }) => (
<div
key={edge}
aria-hidden="true"
{...frame.edgeProps(edge)}
className={`absolute z-10 touch-none ${className}`}
style={{ cursor: edgeCursor(cursor, rtl) }}
/>
))}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
<AssistantHeader
tripId={trip.id}
thread={thread}
tripTitle={trip.title}
dragProps={frame.handleProps}
// Closing only hides the window: an answer being written keeps coming in,
// and the floating button says so until it is opened again.
onClose={() => setMinimized(true)}
/>
<AssistantSurfaceLazy
tripId={trip.id}
tripTitle={trip.title}
variant="window"
density={width < ROOMY_WIDTH ? 'compact' : 'roomy'}
onShowOnMap={onShowOnMap}
onFocusDay={onFocusDay}
/>
</div>
</section>
);
}
export default function AssistantWindow({ trip, onShowOnMap, onFocusDay }: AssistantWindowProps) {
const navigate = useNavigate();
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open);
const minimized = useAssistantStore((s) => s.window.minimized);
const loadStatus = useAssistantStore((s) => s.loadStatus);
const toggle = useAssistantStore((s) => s.toggle);
const [picked, setPicked] = useState<WindowTrip | null>(null);
const [wasOpen, setWasOpen] = useState(open);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useAssistantHotkey(available, toggle);
// A trip picked outside the planner is only for this opening; the next one asks again.
if (open !== wasOpen) {
setWasOpen(open);
if (!open) setPicked(null);
}
const current = trip ?? picked;
const showPicked = useCallback(() => {
if (picked) void navigate(`/trips/${picked.id}`);
}, [navigate, picked]);
if (!available) return null;
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
if (!current) return <PickerWindow onPick={setPicked} />;
// A change in a trip the planner does not show is shown by opening that trip.
const onScreen = !!trip && current.id === trip.id;
const showOnMap = onScreen ? onShowOnMap : showPicked;
return (
<>
<ChatWindow
trip={current}
hidden={minimized}
onShowOnMap={showOnMap}
// Only the planner on screen has days to select.
onFocusDay={onScreen ? onFocusDay : undefined}
/>
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
</>
);
}
function PickerWindow({ onPick }: { onPick: (trip: WindowTrip) => void }) {
const { t } = useTranslation();
const close = useAssistantStore((s) => s.close);
return (
<section
role="dialog"
aria-labelledby="assistant-picker-title"
onKeyDown={(e) => {
if (e.key === 'Escape' && !e.defaultPrevented) close();
}}
className="trek-assistant-pop fixed z-[var(--z-nav)] flex max-h-[min(32rem,calc(100vh-6rem))] w-[22rem] flex-col overflow-hidden rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150"
style={{ insetInlineEnd: LAUNCHER_INSET, bottom: LAUNCHER_INSET, transformOrigin: 'bottom' }}
>
<header className="flex h-10 flex-shrink-0 items-center gap-2 border-b border-edge-faint pe-1.5 ps-3">
<Sparkles size={14} aria-hidden="true" className="flex-shrink-0 text-content-muted" />
<h2 id="assistant-picker-title" className="m-0 flex-1 truncate text-body font-semibold text-content">
{t('assistant.picker.title')}
</h2>
<button
type="button"
onClick={close}
aria-label={t('common.close')}
className="flex h-7 w-7 items-center justify-center rounded-md text-content-muted hover:bg-surface-hover hover:text-content"
>
<X size={16} aria-hidden="true" />
</button>
</header>
<AssistantTripPicker onPick={({ id, title }) => onPick({ id, title })} />
</section>
);
}
@@ -0,0 +1,273 @@
/* The assistant's motion. Calm on purpose: things arrive, nothing bounces.
Every rule here has a still form under reduced motion, from the OS setting
or from the user's own switch (data-reduce-motion on <html>). */
/* The window grows out of the button that opened it. The origin is set inline,
because it is wherever the button sits relative to the window. */
@keyframes trek-assistant-pop {
from {
opacity: 0;
transform: scale(0.94);
}
to {
opacity: 1;
transform: scale(1);
}
}
.trek-assistant-pop {
animation: trek-assistant-pop 240ms var(--ease-drawer);
}
/* Settling after a drag: the window glides into a corner or its other size,
but never while a pointer is moving it. */
.trek-assistant-settle {
transition-property: inset-inline-start, top, width, height;
transition-duration: 220ms;
transition-timing-function: var(--ease-drawer);
}
/* A new message rises in. */
.trek-assistant-enter {
animation: trek-fade-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
/* A reading step names what it does while it runs: the words shimmer. */
.trek-assistant-shimmer {
background-image: linear-gradient(
90deg,
var(--text-muted) 0%,
var(--text-muted) 40%,
var(--text-primary) 50%,
var(--text-muted) 60%,
var(--text-muted) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: trek-shimmer 1.8s linear infinite;
}
/* The caret at the end of an answer that is still being written: after the
last word, so in the last item of a closing list. */
@keyframes trek-assistant-caret {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.2;
}
}
.trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
.trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
content: '';
display: inline-block;
width: 2px;
height: 1em;
margin-inline-start: 2px;
vertical-align: -0.15em;
border-radius: 1px;
background: var(--text-primary);
animation: trek-assistant-caret 1s ease-in-out infinite;
}
/* Markdown in an answer: the planner's text scale, tight rhythm, no images. */
.trek-assistant-md > :first-child {
margin-top: 0;
}
.trek-assistant-md > :last-child {
margin-bottom: 0;
}
.trek-assistant-md p,
.trek-assistant-md ul,
.trek-assistant-md ol,
.trek-assistant-md pre,
.trek-assistant-md blockquote,
.trek-assistant-md table {
margin: 0.5em 0;
}
.trek-assistant-md ul {
list-style: disc;
padding-inline-start: 1.25em;
}
.trek-assistant-md ol {
list-style: decimal;
padding-inline-start: 1.25em;
}
.trek-assistant-md li + li {
margin-top: 0.15em;
}
.trek-assistant-md h1,
.trek-assistant-md h2,
.trek-assistant-md h3,
.trek-assistant-md h4 {
margin: 0.75em 0 0.25em;
font-weight: 600;
font-size: inherit;
}
.trek-assistant-md strong {
font-weight: 600;
}
.trek-assistant-md a {
color: var(--accent-on);
text-decoration: underline;
text-underline-offset: 2px;
}
.trek-assistant-md code {
padding: 0.05em 0.3em;
border-radius: 4px;
background: var(--bg-tertiary);
font-size: 0.92em;
}
.trek-assistant-md pre {
overflow-x: auto;
padding: 0.6em 0.75em;
border-radius: 8px;
background: var(--bg-tertiary);
}
.trek-assistant-md pre code {
padding: 0;
background: none;
}
.trek-assistant-md blockquote {
padding-inline-start: 0.75em;
border-inline-start: 2px solid var(--border-primary);
color: var(--text-secondary);
}
.trek-assistant-md table {
display: block;
overflow-x: auto;
border-collapse: collapse;
}
.trek-assistant-md th,
.trek-assistant-md td {
padding: 0.25em 0.6em;
border: 1px solid var(--border-faint);
text-align: start;
}
.trek-assistant-md hr {
margin: 0.75em 0;
border: 0;
border-top: 1px solid var(--border-faint);
}
/* A closed window still writing an answer: a ring breathes around the floating button. */
@keyframes trek-assistant-busy {
0%,
100% {
transform: scale(1);
opacity: 0.45;
}
50% {
transform: scale(1.18);
opacity: 0;
}
}
.trek-assistant-busy {
animation: trek-assistant-busy 1.8s ease-in-out infinite;
}
/* The dot beside what the assistant does right now: a soft beat, a halo going out. */
@keyframes trek-assistant-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
}
70%,
100% {
box-shadow: 0 0 0 6px transparent;
}
}
.trek-assistant-pulse {
animation: trek-assistant-pulse 1.4s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.trek-assistant-pop,
.trek-assistant-enter {
animation: trek-backdrop-enter 120ms ease-out;
}
.trek-assistant-settle {
transition: none;
}
.trek-assistant-busy,
.trek-assistant-pulse {
animation: none;
}
.trek-assistant-shimmer {
animation: none;
background: none;
color: var(--text-muted);
}
.trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
.trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
animation: none;
}
}
[data-reduce-motion] .trek-assistant-pop,
[data-reduce-motion] .trek-assistant-enter {
animation: trek-backdrop-enter 120ms ease-out;
}
[data-reduce-motion] .trek-assistant-settle {
transition: none;
}
[data-reduce-motion] .trek-assistant-busy,
[data-reduce-motion] .trek-assistant-pulse {
animation: none;
}
[data-reduce-motion] .trek-assistant-shimmer {
animation: none;
background: none;
color: var(--text-muted);
}
[data-reduce-motion] .trek-assistant-md[data-writing] > :last-child:not(ul):not(ol)::after,
[data-reduce-motion] .trek-assistant-md[data-writing] > :is(ul, ol):last-child > li:last-child::after {
animation: none;
}
/* A row of cards that scrolls sideways (found places, the weather): no bar,
and the edges fade where there is more to scroll to. */
.trek-assistant-strip {
--strip-fade-start: 0px;
--strip-fade-end: 0px;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(
to right,
transparent,
black var(--strip-fade-start),
black calc(100% - var(--strip-fade-end)),
transparent
);
mask-image: linear-gradient(
to right,
transparent,
black var(--strip-fade-start),
black calc(100% - var(--strip-fade-end)),
transparent
);
}
.trek-assistant-strip::-webkit-scrollbar {
display: none;
}
[dir='rtl'] .trek-assistant-strip {
-webkit-mask-image: linear-gradient(
to left,
transparent,
black var(--strip-fade-start),
black calc(100% - var(--strip-fade-end)),
transparent
);
mask-image: linear-gradient(
to left,
transparent,
black var(--strip-fade-start),
black calc(100% - var(--strip-fade-end)),
transparent
);
}
.trek-assistant-strip[data-fade-start] {
--strip-fade-start: 24px;
}
.trek-assistant-strip[data-fade-end] {
--strip-fade-end: 24px;
}
@@ -0,0 +1,44 @@
import type { AssistantMessage } from '@trek/shared';
/**
* The next questions an answer suggests, as one tap each: read from what the
* answer holds, not asked from the model, so they cost nothing and come at
* once. Places found ask to narrow them, a plan asks to go on, a forecast asks
* for the other weather. Three at most; the first kind the answer holds wins.
*/
export interface FollowUp {
id: string;
labelKey: string;
}
const PLACES: readonly FollowUp[] = [
{ id: 'cheaper', labelKey: 'assistant.followUp.cheaper' },
{ id: 'closer', labelKey: 'assistant.followUp.closer' },
{ id: 'openNow', labelKey: 'assistant.followUp.openNow' },
];
const PLAN: readonly FollowUp[] = [
{ id: 'nextDay', labelKey: 'assistant.followUp.nextDay' },
{ id: 'relaxed', labelKey: 'assistant.followUp.relaxed' },
{ id: 'dinners', labelKey: 'assistant.followUp.dinners' },
];
const WEATHER: readonly FollowUp[] = [
{ id: 'rain', labelKey: 'assistant.followUp.rain' },
{ id: 'sun', labelKey: 'assistant.followUp.sun' },
];
const GENERAL: readonly FollowUp[] = [
{ id: 'more', labelKey: 'assistant.followUp.more' },
{ id: 'next', labelKey: 'assistant.followUp.next' },
];
/** What an answer offers to ask next; changes are offered only to someone who may make them. */
export function followUpsFor(message: AssistantMessage, canEdit: boolean): readonly FollowUp[] {
const types = new Set(message.parts.map((part) => part.type));
if (canEdit && (types.has('data-trek-change') || types.has('data-trek-applied'))) return PLAN;
if (types.has('data-trek-places')) return PLACES;
if (types.has('data-trek-weather')) return WEATHER;
return types.has('text') ? GENERAL : [];
}
@@ -0,0 +1,372 @@
/**
* FE-AI-HINT-001 to FE-AI-HINT-019
*
* The plan hints, worked out from the trip alone: each rule on both sides of
* its threshold, the data that silences it, and the order, ids and clock
* helpers they rest on.
*/
import type { PlaceOpeningHours } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import type { Accommodation, Assignment, Day, Place, Reservation } from '../../types';
import { formatClockTime } from '../../utils/formatters';
import { type AssistantHint, GAP_MIN_MINUTES, LONG_LEG_KM, tripHints, type TripHintsInput } from './assistantHintRules';
import {
bookingSpan,
clockMinutes,
clockText,
noteMinutes,
spansOverlap,
stopSpan,
weekdayMondayFirst,
} from './assistantHintsTime';
const TODAY = '2026-06-10';
/** A Friday. */
const FRIDAY = '2026-06-12';
/** One kilometre north, in degrees of latitude on the sphere haversineKm uses. */
const KM = 1 / ((2 * Math.PI * 6371) / 360);
function day(id: number, date: string | null = FRIDAY, extra: Partial<Day> = {}): Day {
return buildDay({ id, date, ...extra });
}
interface StopOptions {
time?: string;
end?: string;
duration?: number;
lat?: number;
place?: Partial<Place>;
}
function stop(id: number, dayId: number, opts: StopOptions = {}, extra: Partial<Assignment> = {}): Assignment {
const place = buildPlace({
id: 1000 + id,
name: `Stop ${id}`,
place_time: opts.time ?? null,
end_time: opts.end ?? null,
duration_minutes: opts.duration ?? null,
lat: opts.lat ?? 0,
lng: 0,
...opts.place,
});
return buildAssignment({ id, day_id: dayId, place_id: place.id, order_index: id, place, ...extra });
}
function input(over: Partial<TripHintsInput> & { stops?: Assignment[] }): TripHintsInput {
const { stops = [], ...rest } = over;
const assignments: TripHintsInput['assignments'] = {};
for (const a of stops) (assignments[String(a.day_id)] ??= []).push(a);
return {
today: TODAY,
days: [day(1)],
assignments,
places: stops.map((a) => a.place as Place),
reservations: [],
dayNotes: {},
accommodations: [],
distanceUnit: 'metric',
is12h: false,
...rest,
};
}
function booking(over: Partial<Reservation>): Reservation {
return buildReservation({ day_id: 1, ...over });
}
function stay(start: number, end: number): Accommodation {
return { id: start * 100 + end, trip_id: 1, start_day_id: start, end_day_id: end };
}
function hours(closedOn: number | null, open = '09:00', close = '17:00'): string {
const week: PlaceOpeningHours = Array.from({ length: 7 }, (_, i) =>
i === closedOn ? { closed: true } : { closed: false, open, close }
);
return JSON.stringify(week);
}
const only = (hints: AssistantHint[], rule: AssistantHint['rule']) => hints.filter((h) => h.rule === rule);
describe('overlap', () => {
it('FE-AI-HINT-001: names two stops of a day that share time, once per day, as a warning', () => {
const hints = tripHints(
input({
stops: [
stop(1, 1, { time: '10:00', end: '12:00' }),
stop(2, 1, { time: '11:00', duration: 60 }),
stop(3, 1, { time: '11:30', end: '12:30' }),
],
})
);
const overlaps = only(hints, 'overlap');
expect(overlaps).toHaveLength(1);
expect(overlaps[0]).toMatchObject({
level: 'warn',
dayId: 1,
textKey: 'assistant.hint.overlap',
promptKey: 'assistant.hint.overlap.prompt',
params: { a: 'Stop 1', b: 'Stop 2' },
focus: { dayId: 1, placeIds: [1001, 1002] },
});
});
it('FE-AI-HINT-002: stops that only touch, two bare starts, and a stop timed apart by its own assignment do not overlap', () => {
const overlaps = (stops: Assignment[]) => only(tripHints(input({ stops })), 'overlap').length;
const ten = () => stop(1, 1, { time: '10:00', end: '11:00' });
expect(overlaps([ten(), stop(2, 1, { time: '11:00' })])).toBe(0);
expect(overlaps([stop(1, 1, { time: '10:00' }), stop(2, 1, { time: '10:00' })])).toBe(0);
expect(overlaps([ten(), stop(2, 1, { time: '10:30', duration: 30 }, { assignment_time: '14:00' })])).toBe(0);
expect(overlaps([ten(), stop(2, 1, { time: '10:30' })])).toBe(1);
});
it('FE-AI-HINT-003: collides a stop with a booking that keeps the traveller busy, but not with its own booking', () => {
const lunch = booking({
id: 7,
title: 'Lunch',
reservation_time: `${FRIDAY}T12:30`,
reservation_end_time: `${FRIDAY}T14:00`,
});
const museum = stop(1, 1, { time: '13:00', duration: 60 });
const [hint] = only(tripHints(input({ stops: [museum], reservations: [lunch] })), 'overlap');
expect(hint.params).toEqual({ a: 'Lunch', b: 'Stop 1' });
expect(hint.focus).toEqual({ dayId: 1, placeIds: [1001] });
const quiet = [
{ ...lunch, assignment_id: 1 },
{ ...lunch, type: 'parking' },
{ ...lunch, status: 'cancelled' },
{ ...lunch, ingest_state: 'staged' },
{ ...lunch, reservation_time: '2026-06-13T12:30' },
];
for (const r of quiet) {
expect(only(tripHints(input({ stops: [museum], reservations: [r] })), 'overlap')).toEqual([]);
}
});
});
describe('opening hours', () => {
it('FE-AI-HINT-004: warns about a place that is closed on the weekday of its day, and only on a dated day', () => {
const closed = stop(1, 1, { place: { opening_hours: hours(4) } });
const [hint] = tripHints(input({ stops: [closed] }));
expect(hint).toMatchObject({
rule: 'closed',
level: 'warn',
textKey: 'assistant.hint.closed',
params: { place: 'Stop 1' },
focus: { dayId: 1, placeIds: [1001] },
});
expect(tripHints(input({ days: [day(1, null)], stops: [closed] }))).toEqual([]);
expect(tripHints(input({ days: [day(1, '2026-06-15')], stops: [closed] }))).toEqual([]);
});
it('FE-AI-HINT-005: says when a visit starts before opening or runs past closing, and leaves hours past midnight alone', () => {
const early = stop(1, 1, { time: '08:00', duration: 60, place: { opening_hours: hours(null) } });
expect(tripHints(input({ stops: [early] }))[0]).toMatchObject({
rule: 'outsideHours',
textKey: 'assistant.hint.opensLate',
promptKey: 'assistant.hint.outsideHours.prompt',
params: { place: 'Stop 1', time: '09:00' },
});
const late = stop(1, 1, { time: '16:00', duration: 120, place: { opening_hours: hours(null) } });
expect(tripHints(input({ stops: [late] }))[0]).toMatchObject({
textKey: 'assistant.hint.closesEarly',
params: { time: '17:00' },
});
const fits = stop(1, 1, { time: '10:00', duration: 120, place: { opening_hours: hours(null) } });
const night = stop(1, 1, { time: '08:00', place: { opening_hours: hours(null, '20:00', '02:00') } });
expect(tripHints(input({ stops: [fits] }))).toEqual([]);
expect(tripHints(input({ stops: [night] }))).toEqual([]);
});
});
describe('gaps', () => {
const morning = () => stop(1, 1, { time: '09:00', end: '10:00' });
it('FE-AI-HINT-006: points at three free hours between two timed stops, in the user clock', () => {
const [hint] = tripHints(input({ stops: [morning(), stop(2, 1, { time: '13:00' })] }));
expect(hint).toMatchObject({
rule: 'gap',
level: 'info',
focus: { dayId: 1 },
params: { count: 3, from: '10:00', to: '13:00' },
});
const twelve = tripHints(input({ stops: [morning(), stop(2, 1, { time: '13:00' })], is12h: true }))[0];
expect(twelve.params.from).toBe(formatClockTime('10:00', true));
expect(GAP_MIN_MINUTES).toBe(180);
expect(tripHints(input({ stops: [morning(), stop(2, 1, { time: '12:59' })] }))).toEqual([]);
});
it('FE-AI-HINT-007: a timed note, a booking in between or an unknown end fills the gap; a day without a date still has one', () => {
const later = stop(2, 1, { time: '14:00' });
const note = { id: 1, day_id: 1, text: 'Picnic', time: '11:30' };
expect(tripHints(input({ stops: [morning(), later], dayNotes: { '1': [note] } }))).toEqual([]);
const tour = booking({ type: 'tour', reservation_time: `${FRIDAY}T11:00` });
expect(tripHints(input({ stops: [morning(), later], reservations: [tour] }))).toEqual([]);
expect(tripHints(input({ stops: [stop(1, 1, { time: '09:00' }), later] }))).toEqual([]);
const undated = tripHints(input({ days: [day(1, null)], stops: [morning(), later] }));
expect(undated.map((h) => h.rule)).toEqual(['gap']);
});
});
describe('legs', () => {
it('FE-AI-HINT-008: calls a walk of more than 4 km as the crow flies far, and a ride by bike only past 20 km', () => {
const near = [stop(1, 1), stop(2, 1, { lat: 4 * KM - 1e-9 })];
const far = [stop(1, 1), stop(2, 1, { lat: 4.01 * KM })];
expect(LONG_LEG_KM.walking).toBe(4);
expect(tripHints(input({ stops: near }))).toEqual([]);
expect(tripHints(input({ stops: far }))[0]).toMatchObject({
rule: 'longLeg',
level: 'info',
params: { from: 'Stop 1', to: 'Stop 2', distance: '4 km' },
focus: { dayId: 1, placeIds: [1001, 1002] },
});
const byBike = [stop(1, 1, {}, { leg_transport_mode: 'cycling' }), stop(2, 1, { lat: 15 * KM })];
expect(tripHints(input({ stops: byBike }))).toEqual([]);
const byCar = [stop(1, 1), stop(2, 1, { lat: 100 * KM })];
const driving = day(1, FRIDAY, { default_transport_mode: 'driving' });
expect(tripHints(input({ days: [driving], stops: byCar }))).toEqual([]);
});
it('FE-AI-HINT-009: leaves out stops off the route, road trip and tour stops, and days with a flight', () => {
const far = (extra: Partial<Assignment>, place: Partial<Place> = {}) => [
stop(1, 1),
stop(2, 1, { lat: 10 * KM, place }, extra),
];
expect(tripHints(input({ stops: far({ route_excluded: true }) }))).toEqual([]);
expect(tripHints(input({ stops: far({ tour_place_id: 9 }) }))).toEqual([]);
expect(tripHints(input({ stops: far({}, { stop_type: 'fuel' }) }))).toEqual([]);
const flight = booking({ type: 'flight' });
expect(tripHints(input({ stops: far({}), reservations: [flight] }))).toEqual([]);
});
it('FE-AI-HINT-010: warns when the time between two stops is too short for the way, naming the minutes there are', () => {
const first = stop(1, 1, { time: '10:00', end: '11:00' });
const tight = tripHints(input({ stops: [first, stop(2, 1, { time: '11:30', lat: 3 * KM })] }));
expect(tight[0]).toMatchObject({
rule: 'tightLeg',
level: 'warn',
textKey: 'assistant.hint.tightLeg',
params: { count: 30, from: 'Stop 1', to: 'Stop 2' },
});
expect(tripHints(input({ stops: [first, stop(2, 1, { time: '11:45', lat: 3 * KM })] }))).toEqual([]);
});
});
describe('nights', () => {
const days = [day(1, '2026-06-11'), day(2, FRIDAY), day(3, '2026-06-13'), day(4, '2026-06-14')];
it('FE-AI-HINT-011: joins nights in a row without a stay into one hint, from their first day', () => {
const hints = only(tripHints(input({ days, accommodations: [stay(1, 2)] })), 'noStay');
expect(hints).toHaveLength(1);
expect(hints[0]).toMatchObject({ dayId: 2, dayIndex: 1, params: { count: 2 }, focus: { dayId: 2 } });
expect(only(tripHints(input({ days, accommodations: [stay(1, 4)] })), 'noStay')).toEqual([]);
});
it('FE-AI-HINT-012: a ride through the night covers it; unknown stays, one-day trips and past nights say nothing', () => {
const train = booking({ type: 'train', day_id: 2, end_day_id: 3 });
const hints = only(tripHints(input({ days, accommodations: [stay(1, 2)], reservations: [train] })), 'noStay');
expect(hints.map((h) => [h.dayId, h.params.count])).toEqual([[3, 1]]);
expect(only(tripHints(input({ days, accommodations: null })), 'noStay')).toEqual([]);
expect(only(tripHints(input({ days: [day(1)], accommodations: [] })), 'noStay')).toEqual([]);
const past = [day(1, '2026-06-08'), day(2, '2026-06-09'), day(3, TODAY)];
expect(only(tripHints(input({ days: past, accommodations: [] })), 'noStay')).toEqual([]);
});
});
describe('bookings without a day', () => {
it('FE-AI-HINT-013: names one loose booking and its day, or counts several', () => {
const ferry = booking({ id: 5, title: 'Ferry', day_id: null, reservation_time: `${FRIDAY}T08:00` });
expect(tripHints(input({ reservations: [ferry] }))[0]).toMatchObject({
rule: 'unplacedBooking',
textKey: 'assistant.hint.unplacedBookingDay',
dayId: 1,
focus: { dayId: 1 },
params: { title: 'Ferry' },
});
const undated = { ...ferry, reservation_time: null };
expect(tripHints(input({ reservations: [undated] }))[0]).toMatchObject({
textKey: 'assistant.hint.unplacedBooking',
dayId: null,
focus: null,
});
const two = tripHints(input({ reservations: [ferry, { ...undated, id: 6 }] }));
expect(two[0]).toMatchObject({ textKey: 'assistant.hint.unplacedBookings', params: { count: 2 } });
expect(two[0].id).toBe('unplacedBooking:trip:5,6');
});
it('FE-AI-HINT-014: leaves hotels, cancelled, staged, past and stop-bound bookings alone', () => {
const loose = { day_id: null, reservation_time: null };
const quiet = [
booking({ ...loose, type: 'hotel' }),
booking({ ...loose, status: 'cancelled' }),
booking({ ...loose, ingest_state: 'staged' }),
booking({ ...loose, reservation_time: '2026-06-01T10:00' }),
booking({ ...loose, assignment_id: 3 }),
];
expect(tripHints(input({ reservations: quiet }))).toEqual([]);
});
});
describe('the list', () => {
it('FE-AI-HINT-015: puts warnings first, then by day; past days and stay stops are not read', () => {
const hints = tripHints(
input({
days: [day(1, '2026-06-09'), day(2, '2026-06-11'), day(3, FRIDAY)],
stops: [
stop(1, 1, { time: '10:00', end: '12:00' }),
stop(2, 1, { time: '11:00', end: '12:00' }),
stop(3, 2, { time: '09:00', end: '10:00' }),
stop(4, 2, { time: '14:00' }),
stop(5, 3, { time: '10:00', end: '12:00' }),
stop(6, 3, { time: '11:00', end: '12:00' }, { accommodation_id: 1 }),
stop(7, 3, { time: '11:30', end: '13:00' }),
],
accommodations: [stay(1, 3)],
})
);
expect(hints.map((h) => [h.rule, h.dayId])).toEqual([
['overlap', 3],
['gap', 2],
]);
expect(hints[0].params).toEqual({ a: 'Stop 5', b: 'Stop 7' });
});
it('FE-AI-HINT-016: keeps an id while the plan stays the same and changes it with the times', () => {
const plan = (end: string) =>
input({ stops: [stop(1, 1, { time: '10:00', end }), stop(2, 1, { time: '11:00', end: '12:00' })] });
expect(tripHints(plan('12:00'))[0].id).toBe(tripHints(plan('12:00'))[0].id);
expect(tripHints(plan('12:00'))[0].id).not.toBe(tripHints(plan('11:30'))[0].id);
});
});
describe('the clock', () => {
it('FE-AI-HINT-017: reads HH:MM only, and a note by the time it starts with', () => {
expect(clockMinutes('9:05')).toBe(545);
expect(clockMinutes(' 23:59 ')).toBe(1439);
for (const value of ['24:00', '10:60', '09:30:00', null]) expect(clockMinutes(value)).toBeNull();
expect(noteMinutes('14:30 dinner')).toBe(870);
expect(noteMinutes('dinner at 14:30')).toBeNull();
expect(clockText(545)).toBe('09:05');
});
it('FE-AI-HINT-018: reads the weekday from the date alone, Monday first', () => {
expect(weekdayMondayFirst('2026-06-15')).toBe(0);
expect(weekdayMondayFirst(`${FRIDAY}T23:30:00`)).toBe(4);
expect(weekdayMondayFirst('2026-06-14')).toBe(6);
expect(weekdayMondayFirst('soon')).toBeNull();
});
it('FE-AI-HINT-019: spans stops and bookings, and overlaps them', () => {
expect(stopSpan(stop(1, 1, { time: '10:00', end: '09:00', duration: 30 }))).toEqual({ start: 600, end: 630 });
expect(stopSpan(stop(1, 1, { time: '23:30', duration: 90 }))).toEqual({ start: 1410, end: 1440 });
expect(stopSpan(stop(1, 1))).toBeNull();
const overnight = booking({ reservation_time: `${FRIDAY}T22:00`, end_day_id: 2 });
expect(bookingSpan(overnight, FRIDAY)).toEqual({ start: 1320, end: 1440 });
expect(bookingSpan(booking({ reservation_time: '21:00' }), FRIDAY)).toEqual({ start: 1260, end: null });
expect(bookingSpan(overnight, '2026-06-13')).toBeNull();
expect(spansOverlap({ start: 600, end: null }, { start: 540, end: 660 })).toBe(true);
expect(spansOverlap({ start: 540, end: 660 }, { start: 660, end: null })).toBe(false);
});
});
@@ -0,0 +1,484 @@
import { type AssistantFocus, parsePlaceHours, type PlaceOpeningHours, reservationTypeInfo } from '@trek/shared';
import type {
Accommodation,
Assignment,
AssignmentsMap,
Day,
DayNotesMap,
DistanceUnit,
Place,
Reservation,
} from '../../types';
import { formatClockTime, splitReservationDateTime } from '../../utils/formatters';
import { haversineKm } from '../../utils/geo';
import { formatDistance } from '../../utils/units';
import { dayBookings } from '../Planner/dayDetailModel';
import {
bookingSpan,
clockMinutes,
clockText,
type DaySpan,
noteMinutes,
spanKey,
spansOverlap,
spanTouches,
stopSpan,
weekdayMondayFirst,
} from './assistantHintsTime';
/**
* The plan hints the empty chat offers: things in the trip worth a second
* look, worked out from the data the client already holds. No model and no
* request is involved, so they cost nothing and appear at once. Each hint
* carries a short line, the question a tap sends, and the day or places that
* question is about.
*
* A false alarm costs more than a missed one. Only rules on hard data (times,
* stored opening hours) warn; whatever rests on an estimate is a quiet note.
*/
/** Free time between two stops that counts as a gap. */
export const GAP_MIN_MINUTES = 180;
/** Straight-line distance between two stops that counts as far, by how the leg is travelled. */
export const LONG_LEG_KM = { walking: 4, cycling: 20, other: 150 } as const;
/** Rough speed of a leg, to tell whether the time between two stops is enough. */
const LEG_SPEED_KMH = { walking: 4.5, cycling: 15, other: 50 } as const;
/** How much longer the way is than the straight line. */
const DETOUR_FACTOR = 1.3;
/** How far the estimate may run past the time available before it is worth a word. */
const TIGHT_LEG_SLACK_MINUTES = 10;
export type HintRule =
'overlap' | 'closed' | 'outsideHours' | 'tightLeg' | 'gap' | 'longLeg' | 'noStay' | 'unplacedBooking';
const RULE_ORDER: readonly HintRule[] = [
'overlap',
'closed',
'outsideHours',
'tightLeg',
'gap',
'longLeg',
'noStay',
'unplacedBooking',
];
const WARNS: ReadonlySet<HintRule> = new Set(['overlap', 'closed', 'outsideHours', 'tightLeg']);
/** Bookings that keep the traveller busy at their time, so a stop at the same time collides with them. */
const OCCUPYING_TYPES: ReadonlySet<string> = new Set(['restaurant', 'event', 'tour', 'activity']);
export interface AssistantHint {
/** Stable while the situation stays the same; a changed time or stop makes a new hint. */
id: string;
rule: HintRule;
level: 'warn' | 'info';
/** Position of the day in the trip, for the order; null for the trip as a whole. */
dayIndex: number | null;
/** The day the line names as {day}; the caller words it. */
dayId: number | null;
/** What the question is about, set on the chat before it is sent. */
focus: AssistantFocus | null;
textKey: string;
promptKey: string;
params: Record<string, string | number>;
}
export interface TripHintsInput {
/** The user's calendar date, YYYY-MM-DD; earlier days are left alone. */
today: string;
days: Day[];
assignments: AssignmentsMap;
places: Place[];
reservations: Reservation[];
dayNotes: DayNotesMap;
/** The trip's stays; null while they are not known, which keeps the night rule quiet. */
accommodations: Accommodation[] | null;
distanceUnit: DistanceUnit;
is12h: boolean;
}
interface Stop {
assignment: Assignment;
placeId: number;
name: string;
span: DaySpan | null;
hours: PlaceOpeningHours | null;
}
interface TimedBooking {
reservation: Reservation;
span: DaySpan;
}
interface PlannedDay {
day: Day;
index: number;
stops: Stop[];
/** The day's bookings with a time, the ones that occupy the traveller. */
bookings: TimedBooking[];
hasCarrier: boolean;
noteTimes: number[];
}
interface Ctx {
input: TripHintsInput;
clock: (minutes: number) => string;
}
function isLive(r: Reservation): boolean {
return r.status !== 'cancelled' && r.ingest_state !== 'staged';
}
function isCarrier(r: Reservation): boolean {
return reservationTypeInfo(r.type)?.isCarrier === true;
}
function hint(
rule: HintRule,
key: string,
at: { day: Day; index: number } | null,
fields: Pick<AssistantHint, 'focus' | 'textKey' | 'promptKey' | 'params'>
): AssistantHint {
return {
id: `${rule}:${at?.day.id ?? 'trip'}:${key}`,
rule,
level: WARNS.has(rule) ? 'warn' : 'info',
dayIndex: at?.index ?? null,
dayId: at?.day.id ?? null,
...fields,
};
}
function planDay(day: Day, index: number, input: TripHintsInput, placesById: Map<number, Place>): PlannedDay {
const all = (input.assignments[String(day.id)] ?? []).slice().sort((a, b) => a.order_index - b.order_index);
const toStop = (a: Assignment): Stop => ({
assignment: a,
placeId: a.place_id,
name: a.place.name,
span: stopSpan(a),
hours: parsePlaceHours(placesById.get(a.place_id)?.opening_hours),
});
// A stay's own stop is the overnight block, not a visit.
const stops = all.filter((a) => a.accommodation_id == null).map(toStop);
const booked = dayBookings(day.id, all, input.reservations.filter(isLive));
const bookings: TimedBooking[] = [];
for (const reservation of booked) {
if (!isCarrier(reservation) && !OCCUPYING_TYPES.has(reservation.type)) continue;
const span = bookingSpan(reservation, day.date);
if (span) bookings.push({ reservation, span });
}
const noteTimes = (input.dayNotes[String(day.id)] ?? [])
.map((n) => noteMinutes(n.time))
.filter((m): m is number => m !== null);
return { day, index, stops, bookings, hasCarrier: booked.some(isCarrier), noteTimes };
}
interface Slot {
key: string;
name: string;
span: DaySpan;
placeId: number | null;
assignmentId: number | null;
isStop: boolean;
}
/** A booking hung on a stop, or made for its place, is that stop: the two cannot collide. */
function sameVisit(a: Slot, b: Slot): boolean {
return (
(a.assignmentId !== null && a.assignmentId === b.assignmentId) || (a.placeId !== null && a.placeId === b.placeId)
);
}
function overlapHint(pd: PlannedDay): AssistantHint | null {
const slots: Slot[] = [];
for (const s of pd.stops) {
if (s.span) {
slots.push({
key: `a${s.assignment.id}`,
name: s.name,
span: s.span,
placeId: s.placeId,
assignmentId: s.assignment.id,
isStop: true,
});
}
}
for (const b of pd.bookings) {
slots.push({
key: `r${b.reservation.id}`,
name: b.reservation.title,
span: b.span,
placeId: b.reservation.place_id ?? null,
assignmentId: b.reservation.assignment_id ?? null,
isStop: false,
});
}
slots.sort((a, b) => a.span.start - b.span.start);
for (let i = 0; i < slots.length; i++) {
for (let j = i + 1; j < slots.length; j++) {
const [a, b] = [slots[i], slots[j]];
if (sameVisit(a, b) || !spansOverlap(a.span, b.span)) continue;
const placeIds = [a, b].filter((s) => s.isStop && s.placeId !== null).map((s) => s.placeId as number);
return hint('overlap', `${a.key}@${spanKey(a.span)},${b.key}@${spanKey(b.span)}`, pd, {
focus: placeIds.length > 0 ? { dayId: pd.day.id, placeIds } : { dayId: pd.day.id },
textKey: 'assistant.hint.overlap',
promptKey: 'assistant.hint.overlap.prompt',
params: { a: a.name, b: b.name },
});
}
}
return null;
}
function hoursHint(pd: PlannedDay, stop: Stop, weekday: number, ctx: Ctx): AssistantHint | null {
const hours = stop.hours?.[weekday];
if (!hours) return null;
const focus = { dayId: pd.day.id, placeIds: [stop.placeId] };
if (hours.closed) {
return hint('closed', `p${stop.placeId}`, pd, {
focus,
textKey: 'assistant.hint.closed',
promptKey: 'assistant.hint.closed.prompt',
params: { place: stop.name },
});
}
const open = clockMinutes(hours.open);
const close = clockMinutes(hours.close);
// Hours past midnight are left alone: which day they belong to is a guess.
if (!stop.span || open === null || close === null || close <= open) return null;
const { start } = stop.span;
const end = stop.span.end ?? start;
const key = `p${stop.placeId}@${spanKey(stop.span)}`;
if (start < open) {
return hint('outsideHours', key, pd, {
focus,
textKey: 'assistant.hint.opensLate',
promptKey: 'assistant.hint.outsideHours.prompt',
params: { place: stop.name, time: ctx.clock(open) },
});
}
if (end > close || start >= close) {
return hint('outsideHours', key, pd, {
focus,
textKey: 'assistant.hint.closesEarly',
promptKey: 'assistant.hint.outsideHours.prompt',
params: { place: stop.name, time: ctx.clock(close) },
});
}
return null;
}
function hoursHints(pd: PlannedDay, ctx: Ctx): AssistantHint[] {
const weekday = weekdayMondayFirst(pd.day.date);
if (weekday === null) return [];
return pd.stops.map((s) => hoursHint(pd, s, weekday, ctx)).filter((h): h is AssistantHint => h !== null);
}
/** Whether a booking or a timed note already fills the free time. */
function gapFilled(pd: PlannedDay, from: number, to: number): boolean {
return pd.bookings.some((b) => spanTouches(b.span, from, to)) || pd.noteTimes.some((t) => t > from && t < to);
}
/**
* The first long stretch with nothing planned between two timed stops. A free
* morning or evening is not a gap, and a stop whose end is not known leaves
* the time after it unknown too.
*/
function gapHint(pd: PlannedDay, ctx: Ctx): AssistantHint | null {
const timed = pd.stops.filter((s) => s.span).sort((a, b) => (a.span as DaySpan).start - (b.span as DaySpan).start);
let reach: number | null = null;
for (let k = 0; k < timed.length; k++) {
const span = timed[k].span as DaySpan;
if (k > 0 && reach !== null && span.start - reach >= GAP_MIN_MINUTES && !gapFilled(pd, reach, span.start)) {
return hint('gap', `${reach}-${span.start}`, pd, {
focus: { dayId: pd.day.id },
textKey: 'assistant.hint.gap',
promptKey: 'assistant.hint.gap.prompt',
params: {
count: Math.floor((span.start - reach) / 60),
from: ctx.clock(reach),
to: ctx.clock(span.start),
},
});
}
if (span.end === null) reach = reach !== null && reach > span.start ? reach : null;
else reach = Math.max(reach ?? span.end, span.end);
}
return null;
}
type LegClass = keyof typeof LONG_LEG_KM;
function legClass(mode: string | null | undefined): LegClass {
if (!mode || mode === 'walking') return 'walking';
return mode === 'cycling' ? 'cycling' : 'other';
}
/** A stop on the day's route: placed on the map, and not one a road trip or a tour lays out by itself. */
function routable(s: Stop): boolean {
const a = s.assignment;
return (
a.place.lat != null && a.place.lng != null && !a.route_excluded && !a.place.stop_type && a.tour_place_id == null
);
}
function tightLegHint(pd: PlannedDay, from: Stop, to: Stop, km: number, cls: LegClass): AssistantHint | null {
const leaves = from.span?.end;
const arrives = to.span?.start;
if (leaves == null || arrives == null) return null;
const available = arrives - leaves;
const needed = ((km * DETOUR_FACTOR) / LEG_SPEED_KMH[cls]) * 60;
// Less than nothing is an overlap, which says so itself.
if (available < 0 || needed <= available + TIGHT_LEG_SLACK_MINUTES) return null;
return hint('tightLeg', `a${from.assignment.id}-a${to.assignment.id}@${leaves}-${arrives}:${cls}`, pd, {
focus: { dayId: pd.day.id, placeIds: [from.placeId, to.placeId] },
textKey: 'assistant.hint.tightLeg',
promptKey: 'assistant.hint.tightLeg.prompt',
params: { count: available, from: from.name, to: to.name },
});
}
/**
* The legs between consecutive stops, by straight line: one that is short of
* time, and one that is far for the way it is travelled. A day with a flight,
* train or ferry is left alone, since the distance is the point of it.
*/
function legHints(pd: PlannedDay, ctx: Ctx): AssistantHint[] {
if (pd.hasCarrier) return [];
const route = pd.stops.filter(routable);
let tight: AssistantHint | null = null;
let long: AssistantHint | null = null;
for (let k = 1; k < route.length; k++) {
const [from, to] = [route[k - 1], route[k]];
const cls = legClass(from.assignment.leg_transport_mode ?? pd.day.default_transport_mode);
const km = haversineKm(
{ lat: from.assignment.place.lat as number, lng: from.assignment.place.lng as number },
{ lat: to.assignment.place.lat as number, lng: to.assignment.place.lng as number }
);
const tooTight: AssistantHint | null = tight ? null : tightLegHint(pd, from, to, km, cls);
if (tooTight) {
tight = tooTight;
continue;
}
if (!long && km > LONG_LEG_KM[cls]) {
long = hint('longLeg', `a${from.assignment.id}-a${to.assignment.id}:${cls}`, pd, {
focus: { dayId: pd.day.id, placeIds: [from.placeId, to.placeId] },
textKey: 'assistant.hint.longLeg',
promptKey: 'assistant.hint.longLeg.prompt',
params: { from: from.name, to: to.name, distance: formatDistance(km, ctx.input.distanceUnit) },
});
}
}
return [tight, long].filter((h): h is AssistantHint => h !== null);
}
/** Marks the nights from the day at `start` up to the one before `end`, by their place in the trip. */
function cover(nights: boolean[], start: number | undefined, end: number | undefined): void {
if (start === undefined || end === undefined) return;
for (let i = Math.max(start, 0); i < Math.min(end, nights.length); i++) nights[i] = true;
}
/**
* Nights without a bed: the night after each day but the last, covered by a
* stay or by a ride through it (night train, ferry, flight). Nights in a row
* make one hint, and nights already past count as covered.
*/
function noStayHints(input: TripHintsInput): AssistantHint[] {
const { days, accommodations } = input;
if (!accommodations || days.length < 2) return [];
const indexOf = new Map(days.map((d, i) => [d.id, i]));
const covered = days.slice(0, -1).map((d) => !!d.date && d.date < input.today);
for (const a of accommodations) cover(covered, indexOf.get(a.start_day_id), indexOf.get(a.end_day_id));
for (const r of input.reservations) {
if (!isLive(r) || !isCarrier(r) || r.day_id == null || r.end_day_id == null) continue;
cover(covered, indexOf.get(r.day_id), indexOf.get(r.end_day_id));
}
const hints: AssistantHint[] = [];
for (let i = 0; i < covered.length; i++) {
if (covered[i]) continue;
let nights = 1;
while (i + nights < covered.length && !covered[i + nights]) nights++;
const at = { day: days[i], index: i };
const night = hint('noStay', String(nights), at, {
focus: { dayId: at.day.id },
textKey: 'assistant.hint.noStay',
promptKey: 'assistant.hint.noStay.prompt',
params: { count: nights },
});
hints.push(night);
i += nights - 1;
}
return hints;
}
/**
* Bookings that hang on no day, which the day view therefore never shows. One
* names itself, and its day when its date is a day of the trip; several are
* counted. A booking already behind the user is left alone.
*/
function unplacedHint(input: TripHintsInput): AssistantHint | null {
const loose = input.reservations
.filter((r) => isLive(r) && r.type !== 'hotel' && r.day_id == null && r.assignment_id == null)
.map((r) => ({ r, date: splitReservationDateTime(r.reservation_time).date }))
.filter(({ date }) => !date || date >= input.today);
if (loose.length === 0) return null;
const key = loose
.map(({ r }) => r.id)
.sort((a, b) => a - b)
.join(',');
const fields = { promptKey: 'assistant.hint.unplacedBooking.prompt' };
if (loose.length > 1) {
return hint('unplacedBooking', key, null, {
...fields,
focus: null,
textKey: 'assistant.hint.unplacedBookings',
params: { count: loose.length },
});
}
const { r, date } = loose[0];
const index = date ? input.days.findIndex((d) => d.date === date) : -1;
if (index < 0) {
return hint('unplacedBooking', key, null, {
...fields,
focus: null,
textKey: 'assistant.hint.unplacedBooking',
params: { title: r.title },
});
}
const at = { day: input.days[index], index };
return hint('unplacedBooking', key, at, {
...fields,
focus: { dayId: at.day.id },
textKey: 'assistant.hint.unplacedBookingDay',
params: { title: r.title },
});
}
function byWeight(a: AssistantHint, b: AssistantHint): number {
if (a.level !== b.level) return a.level === 'warn' ? -1 : 1;
const ai = a.dayIndex ?? Number.MAX_SAFE_INTEGER;
const bi = b.dayIndex ?? Number.MAX_SAFE_INTEGER;
if (ai !== bi) return ai - bi;
return RULE_ORDER.indexOf(a.rule) - RULE_ORDER.indexOf(b.rule);
}
/** Every hint for the trip, warnings first, then by day and rule. */
export function tripHints(input: TripHintsInput): AssistantHint[] {
const ctx: Ctx = { input, clock: (m) => formatClockTime(clockText(m), input.is12h) };
const placesById = new Map(input.places.map((p) => [p.id, p]));
const hints: AssistantHint[] = [];
input.days.forEach((day, index) => {
if (day.date && day.date < input.today) return;
const pd = planDay(day, index, input, placesById);
const overlap = overlapHint(pd);
const gap = gapHint(pd, ctx);
if (overlap) hints.push(overlap);
hints.push(...hoursHints(pd, ctx), ...legHints(pd, ctx));
if (gap) hints.push(gap);
});
hints.push(...noStayHints(input));
const unplaced = unplacedHint(input);
if (unplaced) hints.push(unplaced);
return hints.sort(byWeight);
}
@@ -0,0 +1,104 @@
import type { Assignment, Reservation } from '../../types';
import { splitReservationDateTime } from '../../utils/formatters';
/**
* The clock arithmetic behind the plan hints: times of day as minutes since
* midnight, and the stretch of the day a stop or a booking takes. A value
* that is not a plain HH:MM reads as no time at all, so a rule that needs it
* stays quiet rather than guessing.
*/
const MINUTES_PER_DAY = 24 * 60;
/** A stretch of one day in minutes since midnight; `end` is null when only the start is known. */
export interface DaySpan {
start: number;
end: number | null;
}
function minutesOf(match: RegExpExecArray | null): number | null {
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
return hours < 24 && minutes < 60 ? hours * 60 + minutes : null;
}
/** "09:30" as 570; anything that is not HH:MM (or H:MM) is null. */
export function clockMinutes(value: string | null | undefined): number | null {
return minutesOf(/^(\d{1,2}):(\d{2})$/.exec(value?.trim() ?? ''));
}
/** The time a day note starts with, if it starts with one: the note is free text. */
export function noteMinutes(value: string | null | undefined): number | null {
return minutesOf(/^(\d{1,2}):(\d{2})(?!\d)/.exec(value?.trim() ?? ''));
}
/** 570 as "09:30". */
export function clockText(minutes: number): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
}
/**
* The weekday of a calendar date, Monday first like stored opening hours.
* Read in UTC, so the zone of the device cannot move the date a day.
*/
export function weekdayMondayFirst(iso: string | null | undefined): number | null {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso ?? '');
if (!match) return null;
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
return Number.isNaN(date.getTime()) ? null : (date.getUTCDay() + 6) % 7;
}
/**
* When a stop takes place: its own time on this day before the place's, an
* end before a duration. An end at or before the start says nothing, and a
* duration never runs past midnight.
*/
export function stopSpan(assignment: Assignment): DaySpan | null {
const start = clockMinutes(assignment.assignment_time ?? assignment.place.place_time);
if (start === null) return null;
const end = clockMinutes(assignment.assignment_end_time ?? assignment.place.end_time);
if (end !== null && end > start) return { start, end };
const duration = assignment.place.duration_minutes ?? 0;
return { start, end: duration > 0 ? Math.min(start + duration, MINUTES_PER_DAY) : null };
}
/**
* When a booking takes place on its day. A time written for another date than
* the day's says nothing about it, and a booking that ends on a later day runs
* to midnight.
*/
export function bookingSpan(reservation: Reservation, dayDate: string | null | undefined): DaySpan | null {
const begins = splitReservationDateTime(reservation.reservation_time);
if (begins.date && dayDate && begins.date !== dayDate) return null;
const start = clockMinutes(begins.time);
if (start === null) return null;
if (reservation.end_day_id != null && reservation.end_day_id !== reservation.day_id) {
return { start, end: MINUTES_PER_DAY };
}
const end = clockMinutes(splitReservationDateTime(reservation.reservation_end_time).time);
return { start, end: end !== null && end > start ? end : null };
}
/**
* Whether two stretches share time. Two stretches overlap when each starts
* before the other ends; a bare start overlaps only when it lies truly inside
* a stretch, and two bare starts never do.
*/
export function spansOverlap(a: DaySpan, b: DaySpan): boolean {
if (a.end !== null && b.end !== null) return a.start < b.end && b.start < a.end;
if (a.end !== null) return a.start < b.start && b.start < a.end;
if (b.end !== null) return b.start < a.start && a.start < b.end;
return false;
}
/** Whether a stretch, or a bare start, falls into the open window from `from` to `to`. */
export function spanTouches(span: DaySpan, from: number, to: number): boolean {
return span.start < to && (span.end ?? span.start) > from;
}
/** A span as it goes into a hint's id, so a hint about other times is a new hint. */
export function spanKey(span: DaySpan): string {
return `${span.start}-${span.end ?? ''}`;
}
@@ -0,0 +1,101 @@
/**
* FE-AI-MENTION-MODEL-001 to FE-AI-MENTION-MODEL-008
*
* What `@` offers and writes, without React: the entries per kind, the search
* across them, the mention behind a pick, and the text that carries it.
*/
import type { TriggerItem } from '@assistant-ui/react';
import { describe, expect, it } from 'vitest';
import { buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import {
mentionGroups,
mentionLabel,
mentionOf,
searchMentions,
splitMentions,
withMention,
} from './assistantMentionModel';
function t(key: string, params?: Record<string, string | number>): string {
return key === 'dayplan.dayN' ? `Day ${params?.n}` : key;
}
const SOURCES = {
days: [buildDay({ id: 1, title: 'Arrival', date: '2026-11-02' }), buildDay({ id: 2, title: null, date: null })],
places: [
buildPlace({ id: 10, name: 'Louvre' }),
buildPlace({ id: 11, name: 'Musée du Louvre shop' }),
buildPlace({ id: 12, name: 'Bouillon Pigalle' }),
buildPlace({ id: 13, name: ' ' }),
],
reservations: [buildReservation({ id: 20, title: 'Louvre tickets', day_id: 1 })],
};
describe('assistantMentionModel', () => {
it('FE-AI-MENTION-MODEL-001: lists days by their day-list name, places by name, bookings with their day', () => {
const groups = mentionGroups(SOURCES, t, 'en');
expect(groups.day.map((i) => i.label)).toEqual(['Arrival', 'Day 2']);
// A titled day adds its date; an untitled one says nothing more.
expect(groups.day[0].description).toBeTruthy();
expect(groups.day[1].description).toBeUndefined();
// A place without a name has nothing to write into the text and is left out.
expect(groups.place.map((i) => i.id)).toEqual(['place:10', 'place:11', 'place:12']);
expect(groups.reservation[0]).toMatchObject({
label: 'Louvre tickets',
description: 'Arrival',
type: 'reservation',
});
});
it('FE-AI-MENTION-MODEL-002: search ranks a label that starts with the query before a word inside one', () => {
const groups = mentionGroups(SOURCES, t, 'en');
expect(searchMentions(groups, 'louv').map((i) => i.label)).toEqual([
'Louvre',
'Louvre tickets',
'Musée du Louvre shop',
]);
expect(searchMentions(groups, 'llon').map((i) => i.label)).toEqual(['Bouillon Pigalle']);
expect(searchMentions(groups, 'zzz')).toEqual([]);
});
it('FE-AI-MENTION-MODEL-003: search shows at most eight hits', () => {
const places = Array.from({ length: 12 }, (_, i) => buildPlace({ id: 100 + i, name: `Café ${i}` }));
expect(searchMentions(mentionGroups({ ...SOURCES, places }, t, 'en'), 'caf')).toHaveLength(8);
});
it('FE-AI-MENTION-MODEL-004: a pick reads back as its mention, anything else as nothing', () => {
const [louvre] = mentionGroups(SOURCES, t, 'en').place;
expect(mentionOf(louvre)).toEqual({ kind: 'place', id: 10, label: 'Louvre' });
const foreign: TriggerItem = { id: 'x', type: 'command', label: 'Run' };
expect(mentionOf(foreign)).toBeNull();
expect(mentionOf({ ...louvre, metadata: { id: 'ten' } })).toBeNull();
});
it('FE-AI-MENTION-MODEL-005: labels are one trimmed line the server keeps whole', () => {
expect(mentionLabel(' Le Train\nBleu ')).toBe('Le Train Bleu');
expect(mentionLabel('x'.repeat(200))).toHaveLength(120);
});
it('FE-AI-MENTION-MODEL-006: writing @label keeps the rest of the text, once', () => {
expect(withMention('', 'Louvre')).toBe('@Louvre ');
expect(withMention('Lunch near', 'Louvre')).toBe('Lunch near @Louvre ');
expect(withMention('Lunch near ', 'Louvre')).toBe('Lunch near @Louvre ');
expect(withMention('Near @Louvre ', 'Louvre')).toBe('Near @Louvre ');
// A longer label that starts the same is another mention.
expect(withMention('@Romeo ', 'Rome')).toBe('@Romeo @Rome ');
});
it('FE-AI-MENTION-MODEL-008: a sent question splits into text and its mentions, the longest label first', () => {
expect(splitMentions('Is @Louvre tickets still valid at @Louvre?', ['Louvre', 'Louvre tickets'])).toEqual([
{ text: 'Is ', at: 0, mention: false },
{ text: '@Louvre tickets', at: 3, mention: true },
{ text: ' still valid at ', at: 18, mention: false },
{ text: '@Louvre', at: 34, mention: true },
{ text: '?', at: 41, mention: false },
]);
expect(splitMentions('mail me @ home @Lou', ['Louvre'])).toEqual([
{ text: 'mail me @ home @Lou', at: 0, mention: false },
]);
});
});
@@ -0,0 +1,159 @@
import type { DirectiveFormatter, TriggerItem } from '@assistant-ui/react';
import { ASSISTANT_MENTION_KINDS, type AssistantMention } from '@trek/shared';
import type { AssistantMentionItem } from '../../store/assistantStore';
import type { Day, Place, Reservation } from '../../types';
import { dayDate, dayLabel } from '../../utils/dayLabel';
/**
* What `@` in the composer offers and what it writes: the trip's days, places
* and bookings as entries of the popover, `@label` in the text for a pick, and
* the way back from that text to the mentions a question still carries.
*/
export type MentionKind = AssistantMention['kind'];
/** The heading of each list `@` opens. */
export const MENTION_KINDS: Record<MentionKind, { labelKey: string }> = {
day: { labelKey: 'assistant.mention.days' },
place: { labelKey: 'assistant.mention.places' },
reservation: { labelKey: 'assistant.mention.bookings' },
};
type Translate = (key: string, params?: Record<string, string | number>) => string;
/** The longest label the server keeps for a mention. */
const LABEL_MAX = 120;
/** Hits shown while typing after `@`; the list stays one short glance. */
const SEARCH_MAX = 8;
interface MentionSources {
days: readonly Day[];
places: readonly Place[];
reservations: readonly Reservation[];
}
type MentionGroups = Record<MentionKind, TriggerItem[]>;
/** A label as it goes into the text: one line, trimmed, never longer than the server keeps. */
export function mentionLabel(name: string): string {
return name.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX);
}
function entry(kind: MentionKind, id: number, name: string, description?: string | null): TriggerItem[] {
const label = mentionLabel(name);
if (!label) return [];
return [{ id: `${kind}:${id}`, type: kind, label, ...(description ? { description } : {}), metadata: { id } }];
}
/**
* The entries `@` lists, per kind. A day reads as it does in the day list; a
* titled day adds its date, and a booking the day it is on.
*/
export function mentionGroups(sources: MentionSources, t: Translate, locale: string): MentionGroups {
const dayNames = new Map(sources.days.map((d, i) => [d.id, dayLabel(d, i, t, locale)]));
return {
day: sources.days.flatMap((d) =>
entry('day', d.id, dayNames.get(d.id) ?? '', d.title && d.date ? dayDate(d.date, locale) : null)
),
place: sources.places.flatMap((p) => entry('place', p.id, p.name)),
reservation: sources.reservations.flatMap((r) =>
entry('reservation', r.id, r.title, r.day_id ? dayNames.get(r.day_id) : null)
),
};
}
/** 0 for a label that starts with the query, 1 for one with a word that does, 2 for the rest. */
function rankOf(label: string, at: number): number {
if (at === 0) return 0;
return /\s/u.test(label.charAt(at - 1)) ? 1 : 2;
}
/** The entries whose label holds the query, across all kinds, the best matches first. */
export function searchMentions(groups: MentionGroups, query: string): TriggerItem[] {
const q = query.toLocaleLowerCase();
const hits: { item: TriggerItem; rank: number }[] = [];
for (const kind of ASSISTANT_MENTION_KINDS) {
for (const item of groups[kind]) {
const label = item.label.toLocaleLowerCase();
const at = label.indexOf(q);
if (at >= 0) hits.push({ item, rank: rankOf(label, at) });
}
}
return hits
.sort((a, b) => a.rank - b.rank)
.slice(0, SEARCH_MAX)
.map((h) => h.item);
}
/** The mention a picked entry stands for; null for anything the popover did not build. */
export function mentionOf(item: TriggerItem): AssistantMentionItem | null {
const kind = ASSISTANT_MENTION_KINDS.find((k) => k === item.type);
const id = item.metadata?.id;
if (!kind || typeof id !== 'number') return null;
return { kind, id, label: item.label };
}
/** A pick goes into the text as `@label`; the text stays plain, what it names is kept in the store. */
export const MENTION_FORMATTER: DirectiveFormatter = {
serialize: (item) => `@${item.label}`,
parse: (text) => [{ kind: 'text', text }],
};
/** True when no letter or digit follows at `end`, so `@Rome` does not end inside `@Romeo`. */
function endsWord(text: string, end: number): boolean {
return !/[\p{L}\p{N}]/u.test(text.charAt(end));
}
/** Where `@label` stands in the text as a whole word. */
function tokenAt(text: string, label: string): number {
const token = `@${label}`;
for (let at = text.indexOf(token); at >= 0; at = text.indexOf(token, at + 1)) {
if (endsWord(text, at + token.length)) return at;
}
return -1;
}
function hasMention(text: string, label: string): boolean {
return tokenAt(text, label) >= 0;
}
/** The text with `@label ` written at its end, the way a pick from the popover leaves it. */
export function withMention(text: string, label: string): string {
if (hasMention(text, label)) return text;
const gap = text === '' || /\s$/u.test(text) ? '' : ' ';
return `${text}${gap}@${label} `;
}
interface MentionSegment {
text: string;
/** Where the segment starts in the text. */
at: number;
/** True for an `@label` the question named a day, place or booking with. */
mention: boolean;
}
/** A sent question cut into plain text and its `@label` mentions; the longest label wins a tie. */
export function splitMentions(text: string, labels: readonly string[]): MentionSegment[] {
const tokens = [...new Set(labels)]
.filter(Boolean)
.map((l) => `@${l}`)
.sort((a, b) => b.length - a.length);
const out: MentionSegment[] = [];
let start = 0;
let i = 0;
while (i < text.length) {
const token =
text[i] === '@' ? tokens.find((tk) => text.startsWith(tk, i) && endsWord(text, i + tk.length)) : undefined;
if (token) {
if (i > start) out.push({ text: text.slice(start, i), at: start, mention: false });
out.push({ text: token, at: i, mention: true });
i += token.length;
start = i;
} else {
i += 1;
}
}
if (start < text.length) out.push({ text: text.slice(start), at: start, mention: false });
return out;
}
@@ -0,0 +1,179 @@
/**
* FE-AI-MODEL-001 to FE-AI-MODEL-010
*
* The pure half of the assistant's views: how a stored message reaches
* @assistant-ui, what each error offers, how a reading tool is named while it
* runs and once it says where it looks, and which questions the empty chat
* suggests.
*/
import { ASSISTANT_ERROR_CODES, type AssistantMessage } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { errorView, liveStepText, suggestionsFor, toolStepKey, toThreadMessage, tripPhase } from './assistantModel';
const AT = '2026-10-21T08:00:00.000Z';
describe('toThreadMessage', () => {
it('FE-AI-MODEL-001: passes text, tool calls with their approval and TREK data parts through, and drops empty text', () => {
const message: AssistantMessage = {
id: 'm1',
role: 'assistant',
status: 'awaiting_approval',
createdAt: AT,
parts: [
{ type: 'text', text: '' },
{ type: 'text', text: 'Adding it.' },
{
type: 'tool-call',
toolCallId: 'c1',
toolName: 'create_todo',
args: { name: 'Tickets' },
approval: { id: 'c1' },
},
{ type: 'tool-call', toolCallId: 'r1', toolName: 'list_todos', args: {}, result: [], isError: false },
{
type: 'data-trek-applied',
data: { toolCallId: 'c0', entity: 'todo', ids: [1], dayId: null, undoUntil: null },
},
],
};
const like = toThreadMessage(message);
expect(like).toEqual({
id: 'm1',
role: 'assistant',
createdAt: new Date(AT),
content: [
{ type: 'text', text: 'Adding it.' },
{
type: 'tool-call',
toolCallId: 'c1',
toolName: 'create_todo',
args: { name: 'Tickets' },
approval: { id: 'c1' },
},
{ type: 'tool-call', toolCallId: 'r1', toolName: 'list_todos', args: {}, result: [], isError: false },
{
type: 'data-trek-applied',
data: { toolCallId: 'c0', entity: 'todo', ids: [1], dayId: null, undoUntil: null },
},
],
});
// Complete and waiting messages leave the status to the runtime.
expect(like).not.toHaveProperty('status');
});
it('FE-AI-MODEL-002: a stopped or failed message says so', () => {
const base: AssistantMessage = { id: 'm', role: 'assistant', parts: [], status: 'aborted', createdAt: AT };
expect(toThreadMessage(base).status).toEqual({ type: 'incomplete', reason: 'cancelled' });
expect(toThreadMessage({ ...base, status: 'error' }).status).toEqual({ type: 'incomplete', reason: 'error' });
});
});
describe('errorView', () => {
it('FE-AI-MODEL-003: every error code has a line, and at most one action', () => {
for (const code of ASSISTANT_ERROR_CODES) {
const view = errorView(code, { canConnectOwn: true });
expect(view.messageKey).toMatch(/^assistant\.error\./);
if (view.action) expect(view.action.labelKey).toMatch(/^assistant\.errorAction\./);
}
expect(errorView('busy', { canConnectOwn: true }).action?.kind).toBe('continueHere');
expect(errorView('provider_unavailable', { canConnectOwn: true })).toMatchObject({
messageKey: 'assistant.error.providerDown',
action: { kind: 'retry' },
});
expect(errorView('usage_limit', { canConnectOwn: true }).action?.kind).toBe('openUsage');
expect(errorView('offline', { canConnectOwn: true }).action).toBeNull();
});
it('FE-AI-MODEL-004: the daily limit offers an own provider only where the policy allows one', () => {
expect(errorView('daily_limit', { canConnectOwn: true }).action).toEqual({
kind: 'openSettings',
labelKey: 'assistant.errorAction.connectOwn',
});
expect(errorView('daily_limit', { canConnectOwn: false }).action).toBeNull();
});
it('FE-AI-MODEL-008: a provider limit names the provider when known, else keeps the general line', () => {
expect(errorView('rate_limited', { canConnectOwn: true, provider: 'Mistral' }).messageKey).toBe(
'assistant.error.providerLimit'
);
expect(errorView('usage_limit', { canConnectOwn: true, provider: 'ChatGPT' })).toMatchObject({
messageKey: 'assistant.error.providerLimit',
action: { kind: 'openUsage' },
});
expect(errorView('rate_limited', { canConnectOwn: true, provider: '' }).messageKey).toBe(
'assistant.error.rateLimited'
);
expect(errorView('usage_limit', { canConnectOwn: true }).messageKey).toBe('assistant.error.usageLimit');
expect(errorView('generic', { canConnectOwn: true, provider: 'Mistral' }).messageKey).toBe(
'assistant.error.generic'
);
// TREK's own limit never blames the provider.
expect(errorView('too_many_requests', { canConnectOwn: true, provider: 'OpenRouter' }).messageKey).toBe(
'assistant.error.rateLimited'
);
});
it('FE-AI-MODEL-009: waiting changes ask for a decision first, with nothing to press', () => {
expect(errorView('decide_first', { canConnectOwn: true })).toEqual({
messageKey: 'assistant.error.decideFirst',
action: null,
});
});
});
describe('toolStepKey', () => {
it('FE-AI-MODEL-005: names reading tools by what they do, and anything else as working', () => {
expect(toolStepKey('get_trip_summary')).toBe('assistant.step.reading');
expect(toolStepKey('search_nearby_places')).toBe('assistant.step.nearby');
expect(toolStepKey('get_settlement_summary')).toBe('assistant.step.costs');
expect(toolStepKey('create_place')).toBe('assistant.step.working');
expect(toolStepKey('constructor')).toBe('assistant.step.working');
});
it('FE-AI-MODEL-010: a running step that names a place or a query says it, and falls back to its name', () => {
expect(liveStepText('search_nearby_places', 'Trastevere')).toEqual({
key: 'assistant.step.nearbyAt',
params: { place: 'Trastevere' },
});
expect(liveStepText('search_pois', 'Shibuya')).toEqual({
key: 'assistant.step.nearbyAt',
params: { place: 'Shibuya' },
});
expect(liveStepText('search_place', 'gelato')).toEqual({
key: 'assistant.step.searchingFor',
params: { query: 'gelato' },
});
expect(liveStepText('get_weather', 'Kyoto')).toEqual({
key: 'assistant.step.weatherAt',
params: { place: 'Kyoto' },
});
expect(liveStepText('search_nearby_places', null)).toEqual({ key: 'assistant.step.nearby' });
expect(liveStepText('list_places', 'Kyoto')).toEqual({ key: 'assistant.step.reading' });
});
});
describe('suggestions', () => {
it('FE-AI-MODEL-006: the phase follows the dates and whether anything is planned', () => {
const today = '2026-06-12';
expect(tripPhase({ startDate: null, endDate: null, hasPlaces: false, today })).toBe('empty');
expect(tripPhase({ startDate: '2026-07-01', endDate: '2026-07-05', hasPlaces: true, today })).toBe('upcoming');
expect(tripPhase({ startDate: '2026-06-10', endDate: '2026-06-14', hasPlaces: false, today })).toBe('running');
expect(tripPhase({ startDate: '2026-06-12', endDate: null, hasPlaces: true, today })).toBe('running');
expect(tripPhase({ startDate: '2026-06-01', endDate: '2026-06-11', hasPlaces: true, today })).toBe('past');
});
it('FE-AI-MODEL-007: focus comes first, at most four, and nothing that writes for someone who may only read', () => {
const focused = suggestionsFor({ phase: 'upcoming', canEdit: true, place: 'Kinkaku-ji', day: 'Fri 12 Jun' });
expect(focused.map((s) => s.id)).toEqual(['nearPlace', 'placeTips', 'planDay', 'dayTooFull']);
expect(focused[0]?.params).toEqual({ place: 'Kinkaku-ji' });
const readOnly = suggestionsFor({ phase: 'empty', canEdit: false, day: 'Fri 12 Jun' });
expect(readOnly.map((s) => s.id)).toEqual(['dayTooFull', 'mustSee', 'weather']);
expect(readOnly.every((s) => !s.writes)).toBe(true);
expect(suggestionsFor({ phase: 'past', canEdit: true }).map((s) => s.labelKey)).toEqual([
'assistant.suggest.settleUp',
'assistant.suggest.recap',
]);
});
});
@@ -0,0 +1,283 @@
import type { ThreadMessageLike } from '@assistant-ui/react';
import type { AiStatusResponse, AssistantErrorCode, AssistantMessage, AssistantMessagePart } from '@trek/shared';
/**
* Pure helpers the assistant's views share, desktop window and phone screen
* alike: the bridge from stored messages to @assistant-ui, what an error says
* and offers, how a reading tool is named while it runs, and which questions
* the empty chat suggests. No React, and only a type from @assistant-ui, so
* importing this keeps the chat runtime out of the chunk.
*/
type LikePart = Exclude<ThreadMessageLike['content'], string>[number];
const STATUS: Partial<Record<AssistantMessage['status'], ThreadMessageLike['status']>> = {
aborted: { type: 'incomplete', reason: 'cancelled' },
error: { type: 'incomplete', reason: 'error' },
};
function toPart(part: AssistantMessagePart): LikePart | null {
if (part.type === 'text') return part.text ? { type: 'text', text: part.text } : null;
// An attached file is drawn from the stored message; the runtime takes it as one more data part.
if (part.type === 'attachment') {
return { type: 'data-trek-attachment', data: { name: part.name, mime: part.mime, size: part.size } };
}
if (part.type !== 'tool-call') return part;
return {
type: 'tool-call',
toolCallId: part.toolCallId,
toolName: part.toolName,
args: part.args,
...(part.result !== undefined ? { result: part.result } : {}),
...(part.isError !== undefined ? { isError: part.isError } : {}),
...(part.approval ? { approval: part.approval } : {}),
} as LikePart;
}
/**
* A stored message as @assistant-ui reads it (the external store runtime's
* `convertMessage`). Complete and waiting messages carry no status, so the
* runtime derives it: running while the turn streams, waiting while a change
* is undecided.
*/
export function toThreadMessage(message: AssistantMessage): ThreadMessageLike {
const status = STATUS[message.status];
return {
id: message.id,
role: message.role,
createdAt: new Date(message.createdAt),
content: message.parts.map(toPart).filter((p): p is LikePart => p !== null),
...(status ? { status } : {}),
};
}
// ── Errors ──────────────────────────────────────────────────────────────────
/** What the one action under an error line does. */
export type AssistantErrorActionKind = 'retry' | 'continueHere' | 'continue' | 'openSettings' | 'openUsage';
interface AssistantErrorView {
messageKey: string;
action: { kind: AssistantErrorActionKind; labelKey: string } | null;
}
const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
not_connected: { messageKey: 'assistant.error.notConnected', action: null },
busy: {
messageKey: 'assistant.error.busy',
action: { kind: 'continueHere', labelKey: 'assistant.errorAction.continueHere' },
},
daily_limit: {
messageKey: 'assistant.error.dailyLimit',
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.connectOwn' },
},
rate_limited: { messageKey: 'assistant.error.rateLimited', action: null },
needs_reauth: {
messageKey: 'assistant.error.needsReauth',
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.signIn' },
},
usage_limit: {
messageKey: 'assistant.error.usageLimit',
action: { kind: 'openUsage', labelKey: 'assistant.errorAction.viewUsage' },
},
not_eligible: {
messageKey: 'assistant.error.notEligible',
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.otherProvider' },
},
invalid_key: {
messageKey: 'assistant.error.invalidKey',
action: { kind: 'openSettings', labelKey: 'assistant.errorAction.replaceKey' },
},
provider_unavailable: {
messageKey: 'assistant.error.providerDown',
action: { kind: 'retry', labelKey: 'assistant.errorAction.retry' },
},
max_steps: {
messageKey: 'assistant.error.maxSteps',
action: { kind: 'continue', labelKey: 'assistant.errorAction.continue' },
},
empty_answer: {
messageKey: 'assistant.error.emptyAnswer',
action: { kind: 'continue', labelKey: 'assistant.errorAction.continue' },
},
offline: { messageKey: 'assistant.error.offline', action: null },
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
generic: {
messageKey: 'assistant.error.generic',
action: { kind: 'retry', labelKey: 'assistant.errorAction.retry' },
},
};
/** The limits a provider sets; the line names the provider instead of guessing at its plan. */
const PROVIDER_LIMITS: ReadonlySet<AssistantErrorCode> = new Set(['rate_limited', 'usage_limit']);
/**
* The line an error shows and its one action. The daily limit only offers
* connecting an own provider where the policy allows one. A provider's limit
* says whose it is when the provider is known (`provider` is its name, empty
* when not), and keeps the general line otherwise.
*/
export function errorView(
code: AssistantErrorCode,
opts: { canConnectOwn: boolean; provider?: string }
): AssistantErrorView {
const view = ERRORS[code];
if (code === 'daily_limit' && !opts.canConnectOwn) return { ...view, action: null };
if (PROVIDER_LIMITS.has(code) && opts.provider) return { ...view, messageKey: 'assistant.error.providerLimit' };
return view;
}
/**
* The error the user's own connection shows before anything is sent, once it
* stopped working: a sign-in to renew or a key to replace. The window says so
* right away instead of waiting for the next question to fail.
*/
export function connectionErrorOf(status: AiStatusResponse | null): AssistantErrorCode | null {
if (!status?.own || status.effective?.source !== 'user') return null;
if (status.own.status === 'needs_reauth') return 'needs_reauth';
if (status.own.status === 'error') return 'invalid_key';
return null;
}
// ── Tool steps ──────────────────────────────────────────────────────────────
/** `liveKey` names the step with the place or query it is at, `param` is the word that fills it. */
interface StepLook {
stepKey: string;
live?: { liveKey: string; param: 'place' | 'query' };
}
const STEPS: Partial<Record<string, StepLook>> = {
get_trip_summary: { stepKey: 'assistant.step.reading' },
list_places: { stepKey: 'assistant.step.reading' },
search_place: {
stepKey: 'assistant.step.searching',
live: { liveKey: 'assistant.step.searchingFor', param: 'query' },
},
search_pois: { stepKey: 'assistant.step.searching', live: { liveKey: 'assistant.step.nearbyAt', param: 'place' } },
get_place_details: { stepKey: 'assistant.step.details' },
search_nearby_places: {
stepKey: 'assistant.step.nearby',
live: { liveKey: 'assistant.step.nearbyAt', param: 'place' },
},
list_categories: { stepKey: 'assistant.step.categories' },
get_weather: { stepKey: 'assistant.step.weather', live: { liveKey: 'assistant.step.weatherAt', param: 'place' } },
list_todos: { stepKey: 'assistant.step.todos' },
get_settlement_summary: { stepKey: 'assistant.step.costs' },
search_help: { stepKey: 'assistant.step.help' },
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
};
const WORKING = { stepKey: 'assistant.step.working' };
/** How a reading tool is named while it runs: "Reading your plan", never the tool's name. */
export function toolStepKey(toolName: string): string {
return STEPS[toolName]?.stepKey ?? WORKING.stepKey;
}
/**
* How a running step is named once it said where it looks: "Searching near
* Trastevere", "Looking for gelato". The general name when it named nothing
* or the step has no such line.
*/
export function liveStepText(toolName: string, label: string | null): { key: string; params?: Record<string, string> } {
const live = STEPS[toolName]?.live;
if (!live || !label) return { key: toolStepKey(toolName) };
return { key: live.liveKey, params: { [live.param]: label } };
}
// ── Weather ─────────────────────────────────────────────────────────────────
/** A temperature the server sends in °C, in the user's unit, rounded; never "-0". */
export function inUnit(celsius: number, fahrenheit: boolean): number {
return Math.round(fahrenheit ? (celsius * 9) / 5 + 32 : celsius) || 0;
}
// ── Suggestions ─────────────────────────────────────────────────────────────
type TripPhase = 'empty' | 'upcoming' | 'running' | 'past';
/** Where the trip stands on `today` (YYYY-MM-DD, in the user's zone). Dates missing read as not started. */
export function tripPhase(trip: {
startDate: string | null;
endDate: string | null;
hasPlaces: boolean;
today: string;
}): TripPhase {
const { startDate, endDate, today } = trip;
if (endDate && endDate < today) return 'past';
if (startDate && startDate <= today && (!endDate || today <= endDate)) return 'running';
return trip.hasPlaces ? 'upcoming' : 'empty';
}
interface AssistantSuggestion {
id: string;
labelKey: string;
params?: Record<string, string>;
/** Asks the assistant to change the trip, so it is left out for someone who may only read. */
writes?: boolean;
}
const PHASE_SUGGESTIONS: Record<TripPhase, AssistantSuggestion[]> = {
empty: [
{ id: 'planFirstDays', labelKey: 'assistant.suggest.planFirstDays', writes: true },
{ id: 'mustSee', labelKey: 'assistant.suggest.mustSee' },
{ id: 'packingList', labelKey: 'assistant.suggest.packingList', writes: true },
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
],
upcoming: [
{ id: 'emptyDays', labelKey: 'assistant.suggest.emptyDays' },
{ id: 'packingList', labelKey: 'assistant.suggest.packingList', writes: true },
{ id: 'rainPlan', labelKey: 'assistant.suggest.rainPlan' },
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
],
running: [
{ id: 'today', labelKey: 'assistant.suggest.today' },
{ id: 'eatNearby', labelKey: 'assistant.suggest.eatNearby' },
{ id: 'weather', labelKey: 'assistant.suggest.weather' },
{ id: 'openTodos', labelKey: 'assistant.suggest.openTodos' },
],
past: [
{ id: 'settleUp', labelKey: 'assistant.suggest.settleUp' },
{ id: 'recap', labelKey: 'assistant.suggest.recap' },
],
};
const MAX_SUGGESTIONS = 4;
/**
* The questions the empty chat offers: about the place or day in focus first,
* then what fits the trip's phase. The label is also the prompt, so a tapped
* suggestion reads in the chat exactly as it was offered.
*/
export function suggestionsFor(input: {
phase: TripPhase;
canEdit: boolean;
/** The name of the place in focus. */
place?: string | null;
/** The label of the day in focus ("Fri 12 Jun"). */
day?: string | null;
}): AssistantSuggestion[] {
const focused: AssistantSuggestion[] = [];
if (input.place) {
focused.push(
{ id: 'nearPlace', labelKey: 'assistant.suggest.nearPlace', params: { place: input.place } },
{ id: 'placeTips', labelKey: 'assistant.suggest.placeTips', params: { place: input.place } }
);
}
if (input.day) {
focused.push(
{ id: 'planDay', labelKey: 'assistant.suggest.planDay', params: { day: input.day }, writes: true },
{ id: 'dayTooFull', labelKey: 'assistant.suggest.dayTooFull', params: { day: input.day } }
);
}
const picked: AssistantSuggestion[] = [];
for (const suggestion of [...focused, ...PHASE_SUGGESTIONS[input.phase]]) {
if (suggestion.writes && !input.canEdit) continue;
if (picked.some((p) => p.id === suggestion.id)) continue;
picked.push(suggestion);
}
return picked.slice(0, MAX_SUGGESTIONS);
}
@@ -0,0 +1,326 @@
/**
* FE-AI-PARTS-001 to FE-AI-PARTS-014
*
* How an answer is laid out: text, the steps the assistant looked up, the
* places and the weather they found, and one card per batch of proposed
* changes, each change in the state its approval and receipt put it in.
*/
import {
ASSISTANT_CHANGE_LABEL_KEYS,
type AssistantApplied,
type AssistantChangePreview,
type AssistantFoundPlace,
type AssistantMessage,
type AssistantMessagePart,
type AssistantWeatherDay,
} from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { CHANGE_LABELS, hasUndecided, messageBlocks, suggestedFromStrip, undoLeftMs } from './assistantParts';
const AT = '2026-10-21T08:00:00.000Z';
function message(parts: AssistantMessagePart[]): AssistantMessage {
return { id: 'm1', role: 'assistant', status: 'complete', createdAt: AT, parts };
}
function preview(toolCallId: string): AssistantChangePreview {
return {
toolCallId,
verbKey: 'addTodo',
subject: `Todo ${toolCallId}`,
rows: [],
dayId: null,
placeIds: [],
undoable: true,
};
}
function applied(toolCallId: string, undoUntil: string | null = null): AssistantApplied {
return { toolCallId, entity: 'todo', ids: [1], dayId: null, undoUntil };
}
const read = (id: string, result?: unknown): AssistantMessagePart => ({
type: 'tool-call',
toolCallId: id,
toolName: 'list_places',
args: {},
...(result !== undefined ? { result } : {}),
});
const write = (
id: string,
approval: { approved?: boolean; resolution?: 'cancelled' | 'expired' } = {},
outcome: { result?: unknown; isError?: boolean } = {}
): AssistantMessagePart => ({
type: 'tool-call',
toolCallId: id,
toolName: 'create_todo',
args: {},
approval: { id, ...approval },
...outcome,
});
describe('messageBlocks', () => {
it('FE-AI-PARTS-001: keeps text, gathers adjacent reading steps and drops empty text', () => {
const blocks = messageBlocks(
message([
{ type: 'text', text: 'Let me look.' },
read('r1', { ok: 1 }),
read('r2', { ok: 2 }),
{ type: 'text', text: ' ' },
{ type: 'text', text: 'Found two.' },
]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['text', 'steps', 'text']);
expect(blocks[1]).toMatchObject({
kind: 'steps',
steps: [
{ toolCallId: 'r1', running: false },
{ toolCallId: 'r2', running: false },
],
});
});
it('FE-AI-PARTS-002: a step without its result runs only while the answer is being written', () => {
const parts = [read('r1')];
expect(messageBlocks(message(parts), true)[0]).toMatchObject({ steps: [{ running: true }] });
expect(messageBlocks(message(parts), false)[0]).toMatchObject({ steps: [{ running: false }] });
});
it('FE-AI-PARTS-003: the changes of one step share a card, each with its preview, wherever the preview sits', () => {
const blocks = messageBlocks(
message([
write('w1'),
{ type: 'data-trek-change', data: preview('w1') },
write('w2'),
{ type: 'data-trek-change', data: preview('w2') },
]),
false
);
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({
kind: 'change',
items: [
{ toolCallId: 'w1', state: 'pending', preview: { subject: 'Todo w1' } },
{ toolCallId: 'w2', state: 'pending', preview: { subject: 'Todo w2' } },
],
});
});
it('FE-AI-PARTS-004: reads each change state off its approval, result and receipt', () => {
const parts: AssistantMessagePart[] = [
write('applying', { approved: true }),
write('done', { approved: true }, { result: { id: 1 } }),
write('receipt', { approved: true }),
{ type: 'data-trek-applied', data: applied('receipt') },
write('broke', { approved: true }, { result: 'nope', isError: true }),
write('no', { approved: false }),
write('late', { resolution: 'expired' }),
];
const [card] = messageBlocks(message(parts), false);
expect(card?.kind === 'change' && card.items.map((i) => [i.toolCallId, i.state])).toEqual([
['applying', 'applying'],
['done', 'applied'],
['receipt', 'applied'],
['broke', 'failed'],
['no', 'discarded'],
['late', 'expired'],
]);
expect(card?.kind === 'change' && card.items[2]?.applied).toEqual(applied('receipt'));
});
it('FE-AI-PARTS-005: text between two batches of changes splits them into two cards', () => {
const blocks = messageBlocks(message([write('w1'), { type: 'text', text: 'And one more.' }, write('w2')]), false);
expect(blocks.map((b) => b.kind)).toEqual(['change', 'text', 'change']);
});
});
function found(ref: string, extra: Partial<AssistantFoundPlace> = {}): AssistantFoundPlace {
return {
ref,
name: `Place ${ref}`,
address: null,
lat: 41.89,
lng: 12.47,
kind: 'food',
rating: null,
openNow: null,
googlePlaceId: null,
osmId: null,
photoKey: null,
inTripPlaceId: null,
...extra,
};
}
const places = (toolCallId: string, list: AssistantFoundPlace[], more = 0): AssistantMessagePart => ({
type: 'data-trek-places',
data: { toolCallId, places: list, more },
});
function sky(date: string, tempMax: number): AssistantWeatherDay {
return { dayId: null, date, main: 'Clear', tempMax, tempMin: null, rainPct: null, kind: 'forecast' };
}
/** A change of the model that adds a place to day 3: a new one at a spot, or the trip's own by id. */
function adds(id: string, spot: { lat: number; lng: number } | null, placeId: number | null): AssistantMessagePart {
return {
type: 'data-trek-change',
data: {
...preview(id),
effect: {
kind: 'add',
dayId: 3,
fromDayId: null,
placeId,
assignmentId: null,
place: spot && { name: 'New', categoryId: null, ...spot },
},
},
};
}
describe('messageBlocks: found places and weather', () => {
it('FE-AI-PARTS-008: joins the places of all searches into one strip, each once, and steps after it join the row before it', () => {
const blocks = messageBlocks(
message([
read('s1', []),
places('s1', [found('s1:0'), found('s1:1', { googlePlaceId: 'ChIJa' })], 2),
read('s2', []),
places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3),
places('s3', [found('s3:0', { lat: 40 })]),
{ type: 'text', text: 'Three good ones.' },
]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'places', 'text']);
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
// The same name at the same spot, or the same Google id, shows once.
expect(blocks[1]).toMatchObject({
found: { toolCallId: 's3', more: 5, places: [{ ref: 's1:0' }, { ref: 's1:1' }, { ref: 's3:0' }] },
});
});
it('FE-AI-PARTS-009: drops the places a change of the model proposes, and the strip if none is left', () => {
const blocks = messageBlocks(
message([
places('s1', [found('s1:0'), found('s1:1', { lat: 40, inTripPlaceId: 8 }), found('s1:2', { lat: 39 })]),
write('w1'),
adds('w1', { lat: 41.89, lng: 12.47 }, null),
write('w2'),
adds('w2', null, 8),
]),
false
);
expect(blocks[0]).toMatchObject({ kind: 'places', found: { places: [{ ref: 's1:2' }] } });
const none = messageBlocks(
message([places('s1', [found('s1:0')]), write('w1'), adds('w1', { lat: 41.89, lng: 12.47 }, null)]),
false
);
expect(none.map((b) => b.kind)).toEqual(['change']);
});
it('FE-AI-PARTS-010: a place suggested from the strip stays on it and reads as suggested', () => {
const msg = message([
places('s1', [found('s1:0'), found('s1:1', { lat: 40 })]),
{
type: 'tool-call',
toolCallId: 'f1',
toolName: 'add_found_place',
args: { via: 'create_and_assign_place', dayId: 3, name: 'Place s1:0', lat: 41.89, lng: 12.47 },
approval: { id: 'f1' },
},
adds('f1', { lat: 41.89, lng: 12.47 }, null),
]);
const [strip] = messageBlocks(msg, false);
expect(strip).toMatchObject({ kind: 'places', found: { places: [{ ref: 's1:0' }, { ref: 's1:1' }] } });
expect(suggestedFromStrip(msg, found('s1:0'))).toBe(true);
expect(suggestedFromStrip(msg, found('s1:1', { lat: 40 }))).toBe(false);
const inTrip = message([
{
type: 'tool-call',
toolCallId: 'f2',
toolName: 'add_found_place',
args: { placeId: 8 },
approval: { id: 'f2' },
},
]);
expect(suggestedFromStrip(inTrip, found('x', { inTripPlaceId: 8 }))).toBe(true);
expect(suggestedFromStrip(inTrip, found('x', { inTripPlaceId: 9 }))).toBe(false);
});
it('FE-AI-PARTS-014: each suggestion from the strip is a card of its own, apart from the changes around it', () => {
const fromStrip = (id: string, approval: { approved?: boolean } = {}): AssistantMessagePart => ({
type: 'tool-call',
toolCallId: id,
toolName: 'add_found_place',
args: { placeId: 8 },
approval: { id, ...approval },
});
const blocks = messageBlocks(
message([write('w1', { approved: true }), fromStrip('f1', { approved: true }), fromStrip('f2'), write('w2')]),
false
);
expect(blocks.map((b) => (b.kind === 'change' ? b.items.map((i) => i.toolCallId) : b.kind))).toEqual([
['w1'],
['f1'],
['f2'],
['w2'],
]);
});
it('FE-AI-PARTS-011: the weather of all steps is one row, one entry per date, where the first look sat', () => {
const blocks = messageBlocks(
message([
read('w1', {}),
{ type: 'data-trek-weather', data: { days: [sky('2026-11-03', 18), sky('2026-11-02', 20)] } },
{ type: 'text', text: 'Mild.' },
{ type: 'data-trek-weather', data: { days: [sky('2026-11-03', 15)] } },
]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'weather', 'text']);
expect(blocks[1]).toMatchObject({
kind: 'weather',
days: [
{ date: '2026-11-02', tempMax: 20 },
{ date: '2026-11-03', tempMax: 15 },
],
});
});
});
describe('hasUndecided', () => {
it('FE-AI-PARTS-012: is true only while a change waits for the user', () => {
expect(hasUndecided([message([write('w1')])])).toBe(true);
expect(hasUndecided([message([write('w1', { approved: true })])])).toBe(false);
expect(hasUndecided([message([write('w1', { resolution: 'expired' })])])).toBe(false);
expect(hasUndecided([message([read('r1')])])).toBe(false);
});
});
describe('undoLeftMs', () => {
it('FE-AI-PARTS-006: counts down to the end of the undo window and stops at zero', () => {
const now = Date.parse(AT);
expect(undoLeftMs(applied('a', '2026-10-21T08:10:00.000Z'), now)).toBe(10 * 60_000);
expect(undoLeftMs(applied('a', '2026-10-21T07:59:00.000Z'), now)).toBe(0);
expect(undoLeftMs(applied('a', null), now)).toBe(0);
expect(undoLeftMs(null, now)).toBe(0);
});
});
describe('CHANGE_LABELS', () => {
it('FE-AI-PARTS-013: names a route', () => {
expect(CHANGE_LABELS.route.labelKey).toBe('assistant.change.label.route');
});
it('FE-AI-PARTS-007: names every field a change row can carry', () => {
for (const key of ASSISTANT_CHANGE_LABEL_KEYS) {
expect(CHANGE_LABELS[key].labelKey).toBe(`assistant.change.label.${key}`);
}
});
});
@@ -0,0 +1,291 @@
import type {
AssistantApplied,
AssistantChangeEffect,
AssistantChangeLabelKey,
AssistantChangePreview,
AssistantFoundPlace,
AssistantMessage,
AssistantMessagePart,
AssistantPlaces,
AssistantWeatherDay,
} from '@trek/shared';
/**
* How an answer is laid out: its stored parts read as blocks. Text stays
* text; the reading tools of a run sit together as one row of steps; the
* changes the model proposes in one go sit together as one card, each with
* its preview and, once applied, its receipt. The places a search found sit
* as one strip, the newest search's, and the weather the steps looked up as
* one row of days. Pure, so the window and the phone screen lay an answer out
* the same way and a test can pin it.
*/
/** Where the settings card for the user's own AI connection lives. */
export const AI_SETTINGS_PATH = '/settings?tab=integrations';
type ToolCallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
/** One reading tool the assistant ran or runs. */
export interface ToolStep {
toolCallId: string;
toolName: string;
/** Still waiting for its result while the answer is being written. */
running: boolean;
}
/**
* Where a proposed change stands: waiting for the user, being applied,
* applied, failed, discarded, or closed without a decision (expired, or the
* turn was stopped).
*/
export type ChangeState = 'pending' | 'applying' | 'applied' | 'failed' | 'discarded' | 'expired';
export interface ChangeItem {
toolCallId: string;
/** What the card says about it; null until the preview arrived. */
preview: AssistantChangePreview | null;
/** The receipt of an applied change. */
applied: AssistantApplied | null;
state: ChangeState;
}
type MessageBlock =
| { kind: 'text'; key: string; text: string }
| { kind: 'steps'; key: string; steps: ToolStep[] }
| { kind: 'change'; key: string; items: ChangeItem[] }
| { kind: 'places'; key: string; found: AssistantPlaces }
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] };
/** The change "+ Day 3" on a found place makes; the server holds it without a model turn. */
const FOUND_PLACE_TOOL = 'add_found_place';
/** Two points this close (about ten metres) are the same place. */
const SAME_SPOT_DEG = 1e-4;
/** A found place one of the answer's own changes already proposes, so its card would say the same twice. */
function proposed(place: AssistantFoundPlace, effects: readonly AssistantChangeEffect[]): boolean {
return effects.some((effect) => {
if (effect.placeId !== null && effect.placeId === place.inTripPlaceId) return true;
const spot = effect.place;
return (
spot !== null && Math.abs(spot.lat - place.lat) < SAME_SPOT_DEG && Math.abs(spot.lng - place.lng) < SAME_SPOT_DEG
);
});
}
/** Every day the answer's weather steps looked up, one entry per date (the later look wins), by date. */
function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDay[] {
const byDate = new Map<string, AssistantWeatherDay>();
for (const part of parts) {
if (part.type === 'data-trek-weather') for (const day of part.data.days) byDate.set(day.date, day);
}
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
}
/** The handles one place goes by: its ids, and its name at its spot (to about 100 m). */
function placeKeys(place: AssistantFoundPlace): string[] {
const ids = [place.googlePlaceId && `g:${place.googlePlaceId}`, place.osmId && `o:${place.osmId}`];
const spot = `n:${place.name.trim().toLowerCase()}@${place.lat.toFixed(3)},${place.lng.toFixed(3)}`;
return [...ids.filter((key): key is string => !!key), spot];
}
/**
* The places all searches of an answer found, in the order they came, each
* once (also when two searches name it by different ids, as long as name and
* spot agree), with what the searches left out counted together.
*/
function allFound(parts: AssistantMessage['parts']): AssistantPlaces {
const seen = new Set<string>();
const places: AssistantFoundPlace[] = [];
let more = 0;
let toolCallId = '';
for (const part of parts) {
if (part.type !== 'data-trek-places') continue;
toolCallId = part.data.toolCallId;
more += part.data.more;
for (const place of part.data.places) {
const keys = placeKeys(place);
if (keys.some((key) => seen.has(key))) continue;
keys.forEach((key) => seen.add(key));
places.push(place);
}
}
return { toolCallId, places, more };
}
/**
* Adds a change to the change block before it, or starts one. A suggestion
* from the strip is a block of its own, decided apart from the changes before
* and after it, even where the answer joins them.
*/
function pushChange(blocks: MessageBlock[], item: ChangeItem, fromStrip: ReadonlySet<string>): void {
const last = blocks[blocks.length - 1];
const lastId = last?.kind === 'change' ? last.items[last.items.length - 1]?.toolCallId : undefined;
const apart = fromStrip.has(item.toolCallId) || (lastId !== undefined && fromStrip.has(lastId));
if (last?.kind === 'change' && !apart) last.items.push(item);
else blocks.push({ kind: 'change', key: `change-${item.toolCallId}`, items: [item] });
}
/** The steps block a new step joins: the last one, past any strip shown after it. */
function openSteps(blocks: readonly MessageBlock[]): Extract<MessageBlock, { kind: 'steps' }> | null {
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i];
if (block?.kind === 'steps') return block;
if (block?.kind !== 'places' && block?.kind !== 'weather') return null;
}
return null;
}
function changeState(part: ToolCallPart, applied: AssistantApplied | null): ChangeState {
const approval = part.approval;
if (approval?.resolution) return 'expired';
if (approval?.approved === false) return 'discarded';
if (approval?.approved === true) {
if (part.isError) return 'failed';
return applied || part.result !== undefined ? 'applied' : 'applying';
}
return 'pending';
}
/**
* The blocks of a message, in the order its parts came. `writing` says the
* message is still being streamed, so a step without a result is running and
* not merely unanswered.
*
* The places of all its searches join into one strip where the newest search
* sat, each place once, without the ones a change the model proposed in the
* answer already holds; the weather of all steps joins
* into one row where the first look sat. A strip does not split the steps
* around it: a step after it joins the row of steps before it.
*/
export function messageBlocks(message: AssistantMessage, writing: boolean): MessageBlock[] {
const previews = new Map<string, AssistantChangePreview>();
const receipts = new Map<string, AssistantApplied>();
const effects: AssistantChangeEffect[] = [];
// A place the user suggested from the strip stays on it, marked as suggested.
const fromStrip = new Set(
message.parts.flatMap((p) => (p.type === 'tool-call' && p.toolName === FOUND_PLACE_TOOL ? [p.toolCallId] : []))
);
let lastPlaces = -1;
let firstWeather = -1;
message.parts.forEach((part, index) => {
if (part.type === 'data-trek-change') {
previews.set(part.data.toolCallId, part.data);
if (part.data.effect && !fromStrip.has(part.data.toolCallId)) effects.push(part.data.effect);
} else if (part.type === 'data-trek-applied') receipts.set(part.data.toolCallId, part.data);
else if (part.type === 'data-trek-places') lastPlaces = index;
else if (part.type === 'data-trek-weather' && firstWeather < 0) firstWeather = index;
});
const blocks: MessageBlock[] = [];
message.parts.forEach((part, index) => {
if (part.type === 'text') {
if (part.text.trim()) blocks.push({ kind: 'text', key: `text-${index}`, text: part.text });
return;
}
if (part.type === 'data-trek-places' && index === lastPlaces) {
const found = allFound(message.parts);
const places = found.places.filter((place) => !proposed(place, effects));
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } });
return;
}
if (part.type === 'data-trek-weather' && index === firstWeather) {
blocks.push({ kind: 'weather', key: `weather-${index}`, days: weatherDays(message.parts) });
return;
}
if (part.type !== 'tool-call') return;
const preview = previews.get(part.toolCallId) ?? null;
if (part.approval || preview) {
const applied = receipts.get(part.toolCallId) ?? null;
const item: ChangeItem = { toolCallId: part.toolCallId, preview, applied, state: changeState(part, applied) };
pushChange(blocks, item, fromStrip);
return;
}
const step: ToolStep = {
toolCallId: part.toolCallId,
toolName: part.toolName,
running: writing && part.result === undefined,
};
const steps = openSteps(blocks);
if (steps) steps.steps.push(step);
else blocks.push({ kind: 'steps', key: `steps-${part.toolCallId}`, steps: [step] });
});
return blocks;
}
/**
* Whether "+ Day" was already pressed on this found place in the answer: a
* suggestion from the strip names the trip's place by its id, a new one by
* the spot the search found it at.
*/
export function suggestedFromStrip(message: AssistantMessage, place: AssistantFoundPlace): boolean {
return message.parts.some((part) => {
if (part.type !== 'tool-call' || part.toolName !== FOUND_PLACE_TOOL) return false;
const args = part.args as { placeId?: unknown; lat?: unknown; lng?: unknown };
if (place.inTripPlaceId !== null && args.placeId === place.inTripPlaceId) return true;
return (
typeof args.lat === 'number' &&
typeof args.lng === 'number' &&
Math.abs(args.lat - place.lat) < SAME_SPOT_DEG &&
Math.abs(args.lng - place.lng) < SAME_SPOT_DEG
);
});
}
/** Whether a change somewhere in the chat still waits for the user; nothing new can be suggested until then. */
export function hasUndecided(messages: readonly AssistantMessage[]): boolean {
return messages.some((m) =>
m.parts.some(
(p) => p.type === 'tool-call' && !!p.approval && p.approval.approved === undefined && !p.approval.resolution
)
);
}
/** How long an applied change can still be undone, in ms; 0 once it cannot. */
export function undoLeftMs(applied: AssistantApplied | null, now: number): number {
if (!applied?.undoUntil) return 0;
return Math.max(0, Date.parse(applied.undoUntil) - now);
}
/** The label of each field a change row can show. A table, so every key is named where it is read. */
export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }> = {
// A tool's own argument: the row names it in `field`, this is only its fallback.
field: { labelKey: 'assistant.change.label.field' },
order: { labelKey: 'assistant.change.label.order' },
name: { labelKey: 'assistant.change.label.name' },
description: { labelKey: 'assistant.change.label.description' },
address: { labelKey: 'assistant.change.label.address' },
coordinates: { labelKey: 'assistant.change.label.coordinates' },
category: { labelKey: 'assistant.change.label.category' },
day: { labelKey: 'assistant.change.label.day' },
time: { labelKey: 'assistant.change.label.time' },
endTime: { labelKey: 'assistant.change.label.endTime' },
notes: { labelKey: 'assistant.change.label.notes' },
website: { labelKey: 'assistant.change.label.website' },
phone: { labelKey: 'assistant.change.label.phone' },
email: { labelKey: 'assistant.change.label.email' },
openingHours: { labelKey: 'assistant.change.label.openingHours' },
price: { labelKey: 'assistant.change.label.price' },
currency: { labelKey: 'assistant.change.label.currency' },
stopType: { labelKey: 'assistant.change.label.stopType' },
text: { labelKey: 'assistant.change.label.text' },
icon: { labelKey: 'assistant.change.label.icon' },
color: { labelKey: 'assistant.change.label.color' },
position: { labelKey: 'assistant.change.label.position' },
quantity: { labelKey: 'assistant.change.label.quantity' },
weight: { labelKey: 'assistant.change.label.weight' },
bag: { labelKey: 'assistant.change.label.bag' },
packed: { labelKey: 'assistant.change.label.packed' },
dueDate: { labelKey: 'assistant.change.label.dueDate' },
priority: { labelKey: 'assistant.change.label.priority' },
assignee: { labelKey: 'assistant.change.label.assignee' },
type: { labelKey: 'assistant.change.label.type' },
location: { labelKey: 'assistant.change.label.location' },
link: { labelKey: 'assistant.change.label.link' },
confirmation: { labelKey: 'assistant.change.label.confirmation' },
checkIn: { labelKey: 'assistant.change.label.checkIn' },
checkOut: { labelKey: 'assistant.change.label.checkOut' },
stay: { labelKey: 'assistant.change.label.stay' },
members: { labelKey: 'assistant.change.label.members' },
route: { labelKey: 'assistant.change.label.route' },
};
@@ -0,0 +1,119 @@
/**
* FE-AI-SHARE-001 to FE-AI-SHARE-005
*
* What an answer says once it goes to the trip chat: Markdown read as plain
* text, the applied changes on request and only those, and a cut at a word
* end that says so.
*/
import type { AssistantChangePreview, AssistantMessage, AssistantMessagePart } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { chatTextOf, markdownToText, SHARE_MAX_CHARS } from './assistantShare';
const t = (key: string) => (key === 'assistant.share.changesHeading' ? 'Applied:' : `<${key}>`);
function message(parts: AssistantMessagePart[]): AssistantMessage {
return { id: 'msg-3', role: 'assistant', status: 'complete', createdAt: '2026-10-21T08:00:00.000Z', parts };
}
function change(id: string, subject: string, approval: { approved?: boolean }): AssistantMessagePart[] {
const preview: AssistantChangePreview = {
toolCallId: id,
verbKey: 'addPlace',
subject,
rows: [],
dayId: 3,
placeIds: [],
undoable: true,
};
return [
{ type: 'tool-call', toolCallId: id, toolName: 'create_place', args: {}, approval: { id, ...approval } },
{ type: 'data-trek-change', data: preview },
];
}
describe('markdownToText', () => {
it('FE-AI-SHARE-001: reads headings, emphasis, code, lists, links, quotes and tables as plain text', () => {
const text = markdownToText(
[
'## Day 3',
'',
'Go **early** to *Fushimi Inari*, take `JR Nara`.',
'- [Tickets](https://example.com/t)',
'* https://example.com',
'> Bring water',
'',
'| Stop | Time |',
'|---|:--:|',
'| Shrine | 08:00 |',
'```',
'**kept**',
'```',
'',
'',
'',
'Done.',
].join('\n')
);
expect(text).toBe(
[
'Day 3',
'',
'Go early to Fushimi Inari, take JR Nara.',
'• Tickets (https://example.com/t)',
'• https://example.com',
'Bring water',
'',
'Stop, Time',
'Shrine, 08:00',
'**kept**',
'',
'Done.',
].join('\n')
);
});
it('FE-AI-SHARE-002: a link whose text is its address stays one address', () => {
expect(markdownToText('[https://trek.app](https://trek.app)')).toBe('https://trek.app');
});
});
describe('chatTextOf', () => {
it('FE-AI-SHARE-003: adds only the applied changes, under a heading, when asked to', () => {
const msg = message([
{ type: 'text', text: 'I added **two** places.' },
...change('c1', 'Kinkaku-ji', { approved: true }),
{ type: 'data-trek-applied', data: { toolCallId: 'c1', entity: 'place', ids: [9], dayId: 3, undoUntil: null } },
...change('c2', 'Ryoan-ji', { approved: false }),
...change('c3', 'Ginkaku-ji', {}),
]);
expect(chatTextOf(msg, { includeChanges: true, t })).toEqual({
text: 'I added two places.\n\nApplied:\n✓ <assistant.change.verb.addPlace>: Kinkaku-ji',
truncated: false,
hasChanges: true,
});
expect(chatTextOf(msg, { includeChanges: false, t })).toEqual({
text: 'I added two places.',
truncated: false,
hasChanges: true,
});
});
it('FE-AI-SHARE-004: an answer without text or applied changes has nothing to share', () => {
expect(chatTextOf(message(change('c1', 'Kinkaku-ji', {})), { includeChanges: true, t })).toEqual({
text: '',
truncated: false,
hasChanges: false,
});
});
it('FE-AI-SHARE-005: a long answer is cut at a word end within the limit and marked', () => {
const words = Array.from({ length: 1200 }, (_, i) => `word${i}`).join(' ');
const { text, truncated } = chatTextOf(message([{ type: 'text', text: words }]), { includeChanges: false, t });
expect(truncated).toBe(true);
expect(text.length).toBeLessThanOrEqual(SHARE_MAX_CHARS);
expect(text.endsWith('…')).toBe(true);
expect(words.startsWith(text.slice(0, -1))).toBe(true);
expect(words.charAt(text.length - 1)).toBe(' ');
});
});
@@ -0,0 +1,110 @@
import type { AssistantMessage } from '@trek/shared';
import { messageBlocks } from './assistantParts';
/**
* What an answer says once it goes to the trip chat, which shows plain text:
* the Markdown read as text, optionally a line per change the user applied,
* cut to fit a chat message. Pure, so the preview shows exactly what is sent.
*/
/** Room left below the chat's 5000 characters, so the cut and its mark always fit. */
export const SHARE_MAX_CHARS = 4800;
type Translate = (key: string, params?: Record<string, string | number>) => string;
/** A table's divider row (`|---|:--:|`), which has no words. */
const TABLE_RULE = /^\|?(?:\s*:?-+:?\s*\|)+\s*:?-*:?\s*$/;
/** One table row as its cells in a line. */
function tableRow(line: string): string {
return line
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
.filter(Boolean)
.join(', ');
}
/** The marks of one line of running text: emphasis, code, links and pictures. */
function inline(line: string): string {
return line
.replace(/!\[([^\]]*)\]\([^)\s]*\)/g, '$1')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text: string, url: string) => (text === url ? url : `${text} (${url})`))
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, '$1$2')
.replace(/`([^`]+)`/g, '$1');
}
/** One line of Markdown as text, or null for a line that only carries layout (a fence, a table rule). */
function plainLine(line: string): string | null {
if (/^\s*(?:```|~~~)/.test(line)) return null;
if (TABLE_RULE.test(line.trim())) return null;
const heading = /^\s{0,3}#{1,6}\s+(.*)$/.exec(line);
if (heading) return inline(heading[1] ?? '');
const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
if (bullet) return `${bullet[1] ?? ''}• ${inline(bullet[2] ?? '')}`;
const quote = /^\s*>\s?(.*)$/.exec(line);
if (quote) return inline(quote[1] ?? '');
if (line.trim().startsWith('|')) return tableRow(inline(line));
return inline(line);
}
/** Markdown as the chat shows it, as plain text. Code keeps its lines; blank runs shrink to one empty line. */
export function markdownToText(markdown: string): string {
const lines: string[] = [];
let inCode = false;
for (const line of markdown.split('\n')) {
if (/^\s*(?:```|~~~)/.test(line)) {
inCode = !inCode;
continue;
}
if (inCode) {
lines.push(line);
continue;
}
const text = plainLine(line);
if (text !== null) lines.push(text.trimEnd());
}
return lines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** The text cut to `max` characters at the last line or word end before it, marked with an ellipsis. */
function cut(text: string, max: number): { text: string; truncated: boolean } {
if (text.length <= max) return { text, truncated: false };
const head = text.slice(0, max - 1);
const at = Math.max(head.lastIndexOf('\n'), head.lastIndexOf(' '));
// A break far back would drop much of the answer; then the cut goes through the word.
const end = at > max * 0.8 ? at : head.length;
return { text: `${head.slice(0, end).trimEnd()}…`, truncated: true };
}
interface ShareText {
text: string;
truncated: boolean;
/** The answer has applied changes, so "Add the changes" is worth offering. */
hasChanges: boolean;
}
/**
* The chat message an answer becomes: its text, then, when `includeChanges`
* is on, the applied changes in a short list under a heading. Discarded,
* failed and expired changes never go along.
*/
export function chatTextOf(message: AssistantMessage, opts: { includeChanges: boolean; t: Translate }): ShareText {
const blocks = messageBlocks(message, false);
const body = markdownToText(blocks.flatMap((b) => (b.kind === 'text' ? [b.text] : [])).join('\n\n'));
const previews = blocks.flatMap((b) =>
b.kind === 'change' ? b.items.flatMap((i) => (i.state === 'applied' && i.preview ? [i.preview] : [])) : []
);
const lines = previews.map((p) => `✓ ${opts.t(`assistant.change.verb.${p.verbKey}`)}: ${p.subject}`);
const heading = opts.t('assistant.share.changesHeading');
const changes = opts.includeChanges && lines.length > 0 ? [heading, ...lines].join('\n') : '';
const full = [body, changes].filter(Boolean).join('\n\n');
return { ...cut(full, SHARE_MAX_CHARS), hasChanges: lines.length > 0 };
}
@@ -0,0 +1,28 @@
import type { AssistantApplied } from '@trek/shared';
import { showOnMapSearch, showOnMapTarget } from './assistantShowOnMap';
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
toolCallId: 'c1',
entity,
ids,
dayId,
undoUntil: null,
});
describe('assistantShowOnMap', () => {
it('FE-ASSIST-SHOW-001: a place goes to the place, even when it was put on a day', () => {
expect(showOnMapTarget(applied('place', [7], 3))).toEqual({ placeId: 7, dayId: null });
expect(showOnMapSearch(applied('place', [7], 3))).toBe('?place=7');
});
it('FE-ASSIST-SHOW-002: a stop or a note goes to its day', () => {
expect(showOnMapSearch(applied('assignment', [41], 3))).toBe('?day=3');
expect(showOnMapSearch(applied('day_note', [9], 4))).toBe('?day=4');
});
it('FE-ASSIST-SHOW-003: a change with nowhere to point at opens the planner as it is', () => {
expect(showOnMapTarget(applied('packing', [2], null))).toEqual({ placeId: null, dayId: null });
expect(showOnMapSearch(applied('place', [], null))).toBe('');
});
});
@@ -0,0 +1,22 @@
import type { AssistantApplied } from '@trek/shared';
interface ShowOnMapTarget {
/** The place the change made or changed. */
placeId: number | null;
/** The day it lives on, for a note or a stop. */
dayId: number | null;
}
/** Where "Show on map" takes an applied change: the place when it is one, else its day; nothing for the rest. */
export function showOnMapTarget(applied: AssistantApplied): ShowOnMapTarget {
const placeId = applied.entity === 'place' ? (applied.ids[0] ?? null) : null;
return { placeId, dayId: placeId === null ? applied.dayId : null };
}
/** The planner's query for it: `?place=` opens the place, `?day=` selects the day. */
export function showOnMapSearch(applied: AssistantApplied): string {
const { placeId, dayId } = showOnMapTarget(applied);
if (placeId !== null) return `?place=${placeId}`;
if (dayId !== null) return `?day=${dayId}`;
return '';
}
@@ -0,0 +1,25 @@
import type { AssistantApplied } from '@trek/shared';
import { createContext, useContext } from 'react';
/** What the parts deep inside a chat need from whoever shows it. */
export interface AssistantView {
tripId: number;
/** 'window' on the desktop, 'fullscreen' on the phone. */
variant: 'window' | 'fullscreen';
/** How much room the chat has: a narrow window and the phone are 'compact'. */
density?: 'compact' | 'roomy';
/** Shows an applied change in the planner; the button is left out where nothing can show it. */
onShowOnMap?: (applied: AssistantApplied) => void;
/** Selects a day in the planner, from its weather; left out where no planner can show it. */
onFocusDay?: (dayId: number) => void;
/** Brings the map into view where the chat covers it (the phone), before the map moves. */
onRevealMap?: () => void;
}
export const AssistantViewContext = createContext<AssistantView | null>(null);
export function useAssistantView(): AssistantView {
const view = useContext(AssistantViewContext);
if (!view) throw new Error('useAssistantView outside AssistantSurface');
return view;
}

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