Compare commits

..

107 Commits

Author SHA1 Message Date
Maurice 3292a092ae fix(assistant): a session names the follow-ups a read, as they change nothing 2026-10-23 13:18:38 +02:00
Maurice 8afc20e1ab feat(assistant): planning a tour from the chat is one tap and opens it in Tours
- "Plan this" decides the take it holds at once instead of waiting below for a
  second confirmation, says it is being planned, and once the tour is added the
  desktop opens it in the Tours tab to be edited.
- The stops run as a timeline: each walking time sits on the line between two
  stops, read with the stop it leads to, so no time reads as a stop's own.
- An answer about a tour leaves out the strip of places its searches went
  through (hotels, a club in another state).
- "On map" from another tab of the planner brings the map back first, and a
  map that mounts right after a request still carries it out.
2026-10-23 13:11:25 +02:00
Maurice 4365417807 fix(assistant): an expense's or payment's note sits under its change row, not beside its buttons
A long note on the faint line squeezed the row's open, undo and done buttons
into a narrow column. It moves under the split (an expense) or the two people
(a payment), and the faint line may wrap.
2026-10-23 13:11:25 +02:00
Maurice 51c59e28b8 feat(assistant): TREK Studio's assistant floats in the planner's window instead of a column
The column took the page's room and cut the spread off. The Studio now opens
the same movable, resizable window as the planner, kept under its own key and
below the Studio's bar; its frame moved into assistantFrame.ts and
AssistantFrameEdges so both windows share it.
2026-10-23 13:11:25 +02:00
Maurice d6b15efa63 feat(assistant): the model offers the follow-up questions that fit the conversation
A turn may end on suggest_follow_ups: the server answers the call itself, keeps
its questions as data-trek-follow-ups and the chat shows them as chips under
the answer. Without them the chips fall back to what the answer holds, now by
the kind of change: a tour asks about the tour, the day plan's questions only
follow a change to the day plan, and an answer that asks the user something
offers no "tell me more".
2026-10-23 13:10:55 +02:00
Maurice 199a6f03a7 fix(deps): raise dompurify to 3.4.17 and ip-address to 10.7.4
npm audit flagged GHSA-cg2g-xh64-v4q7, GHSA-mrv2-3pqr-j5jr and GHSA-c6gr-qvv4-5v3f
(dompurify <= 3.4.16, critical) and GHSA-rxp7-6hxx-x8q5 (ip-address <= 10.7.3).
The override floors move up and the two lockfile nodes follow, since npm keeps
an override-only node where it is.
2026-10-23 12:58:55 +02:00
Maurice d3f797c803 Merge branch 'main' into dev (4.3.4 version bump) 2026-10-23 12:56:44 +02:00
Maurice bb4412fae5 fix(journey): a designed story keeps its last lines, its frame measured with lines broken at spaces
The design engine sized a text frame by counting characters against the
letters a line holds, as if a line ended mid word. A line breaks at a space,
so a long story needed a line or more than its frame held and the Studio and
the print cut its last sentence off. The measure now wraps the words as the
Studio does, takes each family's letter width as measured on story text,
and sizes the engine's own frames with a few percent of headroom; measured
against the Studio on 1512 texts it falls short in under one percent of
them, where it fell short in 27. The overflow finding keeps the best
estimate, so a frame a person sized is not flagged for a few percent.
2026-10-23 05:07:47 +00:00
Maurice 08d7c37217 fix(journey): a placed list hands on the words after it as a list of their own, which the server and client compile 2026-10-23 04:33:04 +00:00
Maurice fa29ee9ea4 feat(journey): a designed day sets the story of every one of its entries and says which words find no room
A chapter of several entries used to set the story of its first entry only.
Its words now flow in reading order from the opener or the panorama onto the
pages after it: the first entry's story, its pros and cons, then every
further entry's story under a small heading of its title, else its place or
date. A quiet page without a photo runs its column on across both pages, and
the planner gives a chapter as many pages for words as its stories need. A
chapter that tells a story opens on a hero or a panorama only when a page
for its words follows. Pros and cons with no room where they fall wait for a
later page that takes words.

What the pages cannot hold is said once for the whole book, named by entry,
ahead of the notes on stories cut to fit, and an entry two chapters share is
told once.
2026-10-23 04:21:01 +00:00
Maurice 317c1ee5ad fix(journey): the day mark reads as its language writes it, and a country mark names its country by one rule
The day word of every language now says where the number goes ("DAY {day}",
"{day}日目", "第{day}天", "{day}. GÜN"), and one helper builds the mark for the
engine, the auto layout, a template and the Travel panel, so Japanese,
Korean and Chinese no longer read "日目 3". A word stored without the
placeholder is followed by the number as before. The panel's own day word is
gone with its only reader.

A country mark carries the name as its words beside the outline and a flag
mark sets it small beside the flag, the same for the engine's value, the
panel's tile, the assistant's add and a template's country mark; a flag in a
template keeps the place its name was drawn in. A flag or country mark asked
for by its own code is named after that country without the journey having
to name one.
2026-10-23 04:21:01 +00:00
Maurice 49dd591f8f refactor(assistant): the empty call and the unknown origin a helper falls back to are named once 2026-10-23 04:21:01 +00:00
Maurice 6d841f98e0 fix(assistant): a book page card draws the page after as the server's trial made it
The Studio drew the page after a held edit or cover change by running the
card's operations on its own book, which knows nothing of the journey's
photos, words and figures, so a caption from a photo, a text from an entry
or a map from the route came out differently or not at all. The card now
carries the page the server's trial made with the full context, read with
the book's schema and within the card's size, and the Studio draws it while
its book stands at the version the trial ran on. A book that moved on, a
card from before and a page too heavy to carry still run the operations on
the book on screen, the book the change will run on. The receipt leaves the
page out, since an applied change is drawn from the book itself.
2026-10-23 02:29:04 +00:00
Maurice 7e28b8ce6d test(i18n): the first load of a language's strings gets more than a second on a busy runner 2026-10-23 02:18:39 +00:00
Maurice de15c32119 fix(assistant): the German title over proposed changes no longer speaks of a trip, it also heads the book's 2026-10-23 02:07:24 +00:00
Maurice b651144537 fix(studio): the assistant column keeps the chat's own buttons, and the bar fits with its toggle
The dashboard's button reset outranked the utility classes the chat is drawn
with, so inside Studio the apply and discard buttons, the follow up chips and
the page checkboxes of a design card lost their look, the checkboxes so far
that nobody could see them. The column is now left out of that reset. The
assistant toggle shows only its icon below 1520 pixels and the journey's name
gives way before the bar runs past the window, and the inspector floating
over the page has a solid ground, so it stays legible in the dark scheme.
2026-10-23 02:07:24 +00:00
Maurice 6b4d71ab03 test(studio): the assistant column's tests give the thread's viewport the scrolling jsdom lacks 2026-10-23 02:07:24 +00:00
Maurice a11c8bfaa7 fix(studio): the travel panel builds its tiles with the book engine's factories
Figures, countries, maps and marks placed from the panel are the elements the
assistant adds, built by the shared factories; the panel keeps only their
placement. A trip map no longer carries the whole journey's trail, switching a
map's scope re-cuts it the way the engine does and clears the roads of the old
legs, and switching back to the whole journey restores the trail.
2026-10-23 01:45:16 +00:00
Maurice 0095f2105a fix(assistant): design and page cards keep the newest choice and draw every page they touch
An answer or a refusal no longer overwrites a newer page choice, and Apply
waits for every page selection the chat has sent, also after the card was
closed. A delete of several pages draws each page it takes, the receipt opens
the first page the design wrote through the same rule the strip uses, and the
vacay group's choices wait while a change runs.
2026-10-23 01:45:16 +00:00
Maurice 9e40092507 fix(studio): the assistant column draws the page the server applied, and reads the user's role
A page preview draws the spread the server applied with the journey's photos,
texts and figures instead of applying the operations again without them. The
Studio's welcome, privacy banner and header fit a journey chat: a viewer is
told they can only look, the banner speaks of the book, and the trip notes
button stays in the trip chat. Escape closes the header menu before it stops a
reply, a single page renders at the full long edge, the status says a model
reads pages only while its provider has not refused an image, and pointing at
a page in a design card outlines it in the Studio.
2026-10-23 01:45:16 +00:00
Maurice 72c61f242e fix(assistant): book changes undo per page, and the Studio prompt fits the user's rights
Changes held in one step are applied in the order the chat shows them, so
"undo all" walks them back newest first. A page put back is checked against
the digest of the page as the change left it, so a save to another page no
longer blocks the undo; restore_book_page takes these digests as an option and
keeps the whole book check without them. The book card counts the user's own
photos, the preview tool describes itself per role, a copy into another trip
names no places, a held vacay toggle says up front whether it can be taken
back, and a viewer's Studio prompt names none of the design's tools.
2026-10-23 01:45:16 +00:00
Maurice cee82bd147 fix(journey): applying a design checks the rights first, and the colour signals lose dead fallbacks
A viewer applying a design gets 403 and an outsider 404 before the draft is
read. The design tools' rights are pinned for outsiders and viewers, the apply
of a new book's pages is covered branch by branch, and a viewer's journey
session no longer offers the design's reads. The colour signals and the grey
statistics drop fallbacks that could never run, and the palette keeps one
fixed order for equal shares.
2026-10-23 01:45:16 +00:00
Maurice 2becbd17bc fix(book): the designed book paces its bursts, lists and maps the way the design asks
A burst of four or more photos within a quarter of an hour becomes one grid,
no beat runs three times in a row, and an entry's pros and cons go into the
room under its words on the opener, the panorama or the quiet page, with a
note when they find none. A chapter map is the map of the chapter's trip, and
entries on no trip are named in the notes. The sorts compare by code unit, the
summary metrics and the page lookups exist once, and a flag or country mark
asked for by its own code is labelled with that country.
2026-10-23 01:45:16 +00:00
Maurice 7d736e1a34 test(assistant): the new end to end tests create their book inside a request context 2026-10-22 22:59:32 +00:00
Maurice 3eded5f5be fix(assistant): taking a proposal needs place rights, and a card's choices wait while a change runs
Plan this checks place_edit like the tour routes, the include choices of a
run or group stay disabled while the chat applies a change, and a follow-up
can say when it applies, so Confirm the pending bookings shows only when the
card holds a pending booking of this trip.
2026-10-22 22:46:35 +00:00
Maurice cf8cf9058d refactor(assistant): the booking package describes its own writes, and the prompt reads as one
The reservation and booking import describers move from the core preview
helpers to the booking card package, the collab domain names the whole
area, an unused vacay label is gone, and the trip, Studio and design prompt
lines no longer contradict each other or name tools a scope is not offered.
2026-10-22 22:46:35 +00:00
Maurice 7b15135ebf test(assistant): the page image per provider and a whole book design through the chat, end to end
The page image test uploads through the real renders route after the real
privacy acknowledgement, for Anthropic, OpenAI, Gemini and the ChatGPT plan,
and keeps OpenRouter and an unacknowledged model to words. The book design
test drives design_book and a held apply_book_design through approval and
undo, with outsiders, editors of another thread and viewers refused.
2026-10-22 22:46:35 +00:00
Maurice 6c4d743259 test(assistant): book card tests run in a request context and match a journey without a book
The book card e2e calls the book services inside a request context, the
preview expects the design id beside the operations, a trial without a book
has no version, the JPEG fixture keeps its signature, and the upload test
checks the file name where the client sets it.
2026-10-22 22:43:48 +00:00
Maurice 3b9edc3a90 fix(journey): the design's rhythm counts the pages it built, and a card body keeps its state through the apply
A quiet page the opener took the whole story from was dropped but still
counted, so the next hero came one page early. The change card's body now
sits beside its row, so the book design strip keeps the chosen pages while
the apply runs, and a card part that failed is drawn again on the next state.
2026-10-22 22:43:48 +00:00
Maurice 51bfebdfb6 fix(ci): narrow the page edit result for the non-strict compile, the invite signature within its line, one journey key used 2026-10-22 22:03:35 +00:00
Maurice 6c43304205 fix(ci): book frames typed for every compile mode, promise checks and the store's length
The shared book engine takes the schema's own frame type, so the client's
non-strict compile accepts a zod frame; promise lookups compare against
undefined, the render wait keeps its timer const, the notification store
moves its read and unread update into one helper, and the registry test
counts the two book packages.
2026-10-22 21:51:44 +00:00
Maurice ab02794ff2 fix(notifications): notifications sent from the services name their actor by name
The routes on dev now name the actor of a notification by username instead
of the address. The chat, note, invite and packing notifications the
services send for both surfaces read the name too, so they no longer go out
with an empty actor.
2026-10-22 21:51:44 +00:00
Maurice f5197159f7 chore(ci): baselines follow the Studio code that moved to shared 2026-10-22 21:34:16 +00:00
Maurice 3516c0b6b2 feat(assistant): the book design card shows each page before and after
The proposal lists its pages in pairs, lets the user leave pages out before
applying, and shows what the apply skipped on the receipt.
2026-10-22 21:34:16 +00:00
Maurice bda07d6a3e feat(journey): the assistant designs the whole book as one proposal
design_book, revise_book_design and apply_book_design work on a draft, the
apply keeps the old book as a revision for undo, skips pages changed since,
and broadcasts once after the commit. Book level tools set the page setup,
duplicate pages and save layouts.
2026-10-22 21:34:15 +00:00
Maurice 09ffc7d946 feat(studio): the assistant sits beside the book and draws the page it looks at
A chat column in the Studio with its own privacy notice, a live page snapshot
through modern-screenshot that leaves out photos of other people and foreign
map tiles, and flushes before every turn and apply.
2026-10-22 21:34:15 +00:00
Maurice 8b93898b73 feat(assistant): models that read images see the live page
The Studio draws the page on request and uploads it once, each provider gets
the image where it expects it, and a refusal falls back to the text
description. No image is stored.
2026-10-22 21:34:15 +00:00
Maurice 499458f8b2 feat(assistant): book page changes show as cards with the page before and after
A held book write is described with its findings, drawn as a thumbnail pair,
bounced once when a severe finding is left, and undone in one step.
2026-10-22 21:34:15 +00:00
Maurice 51ed038374 feat(assistant): a chat can be about a journey and its book
Threads carry a journey scope next to the trip scope, with journey rights
(outsider 404, viewers without write tools, addon off), its own routes and a
book context, and the client keys chats by trip or journey.
2026-10-22 21:34:15 +00:00
Maurice 2c98ae8e4a feat(photos): a photo knows its colours and its calm areas
Photo signals move to version 3: a deterministic palette, a busy grid and
the point of most detail, stored as measured values, never as image bytes.
2026-10-22 21:34:15 +00:00
Maurice 4570ace78c feat(journey): the shared book engine lays out, edits and designs whole books
Every element kind and Studio tool gets an op, the auto layout and its
templates move from the client to shared with a parity fixture, and a pure
design engine turns directives into a finished book with design findings.
2026-10-22 21:34:15 +00:00
Maurice cdb473d427 fix(ci): keep the Studio files on the format baseline until the wave that moves them lands 2026-10-22 21:34:15 +00:00
Maurice 4a782c6d07 chore(client): the charging info is formatted now, off the format baseline 2026-10-22 21:34:15 +00:00
Maurice c8ce36e9af fix(sonar): locale-aware sorts, linear share and redaction patterns, focus only on controls 2026-10-22 21:34:15 +00:00
Maurice e291735c95 fix(ci): the tour card's open label keys stay readable to the key check, and the road trip unused-key count lowered 2026-10-22 21:34:15 +00:00
Maurice de4f72020e fix(ci): the status badge reads its look from a shared module, not the Collections page 2026-10-22 21:34:15 +00:00
Maurice 84ce492e2a style(client): imports ordered the way the shared Prettier config sorts them 2026-10-22 21:34:15 +00:00
Maurice 9383ac63a8 fix(ci): a line break in the format check, and two test type errors 2026-10-22 21:34:15 +00:00
Maurice 9b08da8e0b fix(ci): collection lookups typed as partial, card formatting, two unused exports, and the format check says when Prettier gave up 2026-10-22 21:34:15 +00:00
Maurice 6099615337 fix(ci): strict-mode types in three cards, photo measurements marked independent, and the strict ratchet names its errors 2026-10-22 21:34:15 +00:00
Maurice f669c1f322 fix(ci): card points re-typed for the non-strict builds, formatting, strict ratchet and two assistant e2e checks 2026-10-22 21:34:15 +00:00
Maurice d8bd6ab9e2 fix(ci): typed card points, the managed take route, and the failing card tests and ratchets 2026-10-22 21:34:15 +00:00
Maurice fd01a090fc test(assistant): shared card tests, wiki rows and ratchets after the card packages 2026-10-22 21:34:15 +00:00
Maurice 0f5ecb70a2 feat(assistant): packing list and to-do cards 2026-10-22 21:34:15 +00:00
Maurice 984ee67517 feat(assistant): journal entry cards, and markdown stripped in shared 2026-10-22 21:34:15 +00:00
Maurice a8ff98b660 feat(assistant): vacation calendar cards with the days left 2026-10-22 21:34:15 +00:00
Maurice c31b2162ad feat(assistant): passport, country and bucket list cards 2026-10-22 21:34:15 +00:00
Maurice a440aefd43 feat(assistant): collection list and saved place cards 2026-10-22 21:34:15 +00:00
Maurice 6e92c2777c feat(assistant): chat, note and poll cards drawn like the Collab tab 2026-10-22 21:34:15 +00:00
Maurice b4e188fbfc feat(assistant): booking and stay cards, and a read of the bookings with their details 2026-10-22 21:34:15 +00:00
Maurice b52990bf27 feat(assistant): expense, payment and settle-up cards, and expense links into Costs 2026-10-22 21:34:15 +00:00
Maurice c94545419b feat(assistant): road trip, charging, corridor and transit cards 2026-10-22 21:34:15 +00:00
Maurice 59313dfaff feat(assistant): hike, walk and route cards with their lines on the map, and "Plan this" 2026-10-22 21:34:14 +00:00
Maurice 8547006742 feat(studio): the open editor takes saves made elsewhere and keeps its own undo 2026-10-22 21:34:14 +00:00
Maurice 809002adb3 feat(studio): change a book page by page over MCP, and pick its photos from the gallery 2026-10-22 21:34:14 +00:00
Maurice 18daedecda feat(photos): a photo keeps its quality signals, measured once off the event loop 2026-10-22 21:34:14 +00:00
Maurice 92f059f313 fix(ci): the book engine's template names count as reached, and formatting 2026-10-22 21:34:14 +00:00
Maurice 8516441cab fix(ci): formatting and two exports nothing imports 2026-10-22 21:34:14 +00:00
Maurice 9231eb4db6 fix(ci): message part types across the strict shared build, formatting and trip member write tests 2026-10-22 21:34:14 +00:00
Maurice 18b8e4af61 fix(ci): parse errors, two leaky tests and the lint ratchets after the parity round 2026-10-22 21:34:14 +00:00
Maurice 8365d77c1d feat(client): open views follow member, settings, tag, atlas and journey changes made elsewhere 2026-10-22 21:31:23 +00:00
Maurice 42ce65708f fix(realtime): journey, atlas, vacay and collection writes in parity 2026-10-22 21:31:23 +00:00
Maurice a79c3778e7 fix(realtime): trip, member, chat, file, settings, tag and notification writes in parity 2026-10-22 21:30:39 +00:00
Maurice 8fe2ae80af fix(realtime): budget, packing and todo writes send what REST sends 2026-10-22 21:30:39 +00:00
Maurice 588ec8feb1 fix(realtime): road trip vias and day boundaries send the same events everywhere 2026-10-22 21:30:39 +00:00
Maurice dbb70fc525 fix(realtime): booking writes share one use case on REST and MCP 2026-10-22 21:30:39 +00:00
Maurice 2563afa582 fix(realtime): place writes over MCP and plugins send what REST sends 2026-10-22 21:30:39 +00:00
Maurice 3967ca2790 feat(assistant): a card framework every addon package plugs into 2026-10-22 21:30:39 +00:00
Maurice d2a2728cde feat(studio): the book engine in shared: layouts, grids, page ops and findings 2026-10-22 21:30:39 +00:00
Maurice acd3b3aafc feat(tours): a hike's day with sun, weather and warnings, and the proposed line kept for create 2026-10-22 21:30:39 +00:00
Maurice 0427ccc23b test(realtime): broadcast parity for every MCP write tool
A suite under tests/integration/broadcast-parity boots the app once per
group, runs a write tool through a real MCP session the way the
assistant attaches the registry, runs its REST twin on a twin scene, and
records every broadcast. Per case it checks the exact events in order and
count, that no MCP broadcast skips a socket (the asking tab hears its own
change), that nothing is sent inside a transaction, that every payload
parses with its registered schema, that REST sends the same sequence to
the same room and recipients, and that MCP's keys cover REST's.

A registry test lists the write tools as an MCP client sees them and
fails on a tool without a row, a stale row, or an exemption without a
reason; exemptions run too and must send nothing, and the tools still
waiting for a case are frozen in a list that may only shrink.

The new events the next changes send are registered now (member:updated,
todo:reordered, trips:changed, settings:updated, tags:changed,
notification:read-all, journey:updated, journey:deleted, atlas:changed);
the client keeps them apart as awaiting a sender, and the journey page
already leaves a journey that journey:deleted says is gone.
2026-10-22 21:30:39 +00:00
Maurice 56f26b5efd fix(planner): a stay booked elsewhere shows without a reload
The server sends accommodation:created and accommodation:updated to the
whole trip, the assistant's writes included, but the client listed both
as ignored and reloaded stays only after a delete. A stay the assistant,
a fellow traveller, a plugin or a booking import added therefore showed
in the sidebar, on the map and in the day panel only after a reload.

Both events now refresh the stays like the deletes do, in the planner
(desktop and phone, which share its hook) and in the day panel. The
refresh is bundled per burst, since one assistant step can apply many
changes, a newer answer always wins over an older one, and a stay being
edited in the day panel that was deleted or moved elsewhere closes its
editor instead of letting the form overwrite another stay.

A browser test books a stay from a second context and sees it appear in
the open planner, on the desktop and on the phone.
2026-10-22 21:30:39 +00:00
Maurice 6d6efddc4e feat(assistant): expenses, shares, wind, journey tracks and Dawarich checks as MCP tools
- list_budget_items lists a trip's expenses with their ids, payers and
  splits, page by page so no page is cut down on its way to a model;
  get_budget_per_person answers who paid and owes what. Both in a new
  budget-items.mcp.ts, checked like their REST twins.
- get_wind_forecast reads the hourly wind, gusts and direction of a day.
- get_journey_tracks lists a journey's GPX tracks; the line for drawing
  rides under a key the provider never receives.
- The Dawarich wishlist scan and the countries the recordings suggest
  become tools, with their confirming writes, in DawarichAtlasMcp in the
  Atlas group; the shared time window check refuses alike on REST and
  MCP, and the model never sees when the user was last seen in a city.
- The journal's map, tracks and recorded days load with the journey
  group instead of riding in the core of every step.
- The prompt points at list_budget_items for expense ids (the trip
  summary never carried them) and names the shares and the wind tools;
  each new read has its own live step.
2026-10-22 21:30:38 +00:00
Maurice 3c0f324266 feat(tours): plan, create and change hikes on the server, for the assistant too
The hiking costing moves to shared, so the planner and the server ask the
router the same way. The road trip router learns to plan a hike on foot
with that costing (Valhalla only: OSRM knows no trail difficulty), to read
heights along a line in chunks and to read the OSM ways a line follows,
all through its one paced queue. A hike router builds on it: the way, its
heights, distance, climb and DIN walking time, and the SAC grades of its
stretches from the trail grade service, each part best effort.

New MCP tools in HikePlanMcp, in the assistant's tours group:
- propose_hike plans a hike without saving it.
- create_hike saves a confirmed hike through the planner's own creation;
  a router that does not answer leaves it pending for the planner, a way
  that cannot be planned is refused and nothing is saved.
- update_tour changes a saved hike against its revision, keeping linked
  stations and refusing what would silently drop drawn legs or stages.
- import_tour_gpx takes a GPX file's text in as tours, like the import.
Same permission checks as REST, broadcasts from the services.

In the assistant a new hike or city walk is an "Add tour" card with its
route and difficulty, undone through delete_place while the tour is
untouched, and a hike being planned shows as a live step.
2026-10-22 21:30:38 +00:00
Maurice f60f84dc6d i18n(br): the assistant and AI settings in Brazilian Portuguese
'br' is TREK's code for Brazilian Portuguese, but the assistant's strings
and the admin's AI settings added on this branch were written in Breton.
All of them now read in Brazilian Portuguese, in the words the other
Portuguese screens use for trips, days, places, bookings and expenses.
2026-10-22 21:30:38 +00:00
Maurice cf94b45b65 feat(assistant): the facts of a change as badges
The faint line under a change's name showed its facts as loose icon and
text pairs. They are now the planner sidebar's SoftPill badges, icon and
value in a small round pill, a changed value struck and followed by the
new one inside the same badge. The action stays plain faint text in
front of them, and the line wraps instead of cutting a badge off in a
narrow chat window.
2026-10-22 21:30:38 +00:00
Maurice f0a3fe9dbb 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-22 21:30:38 +00:00
Maurice 70c7d1ba3f 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-22 21:30:38 +00:00
Maurice 7c57b1e818 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-22 21:30:38 +00:00
Maurice a372313ea1 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-22 21:30:37 +00:00
Maurice 422e213438 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-22 21:30:37 +00:00
Maurice d1ee2c05f4 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-22 21:30:37 +00:00
Maurice 7bb5784913 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-22 21:30:37 +00:00
Maurice ccaa0247a5 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-22 21:30:37 +00:00
Maurice c37d3f773e 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-22 21:30:37 +00:00
Maurice 4406d5085f i18n(fr): translate the road trip domain name 2026-10-22 21:30:37 +00:00
Maurice 0fb5d0a8c2 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-22 21:30:36 +00:00
Maurice e9b3e135eb 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-22 21:30:36 +00:00
Maurice 13f7b00b35 style: format two ai-connections files 2026-10-22 21:30:36 +00:00
Maurice 188c6153d5 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-22 21:30:36 +00:00
Maurice efa3ea4879 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-22 21:30:36 +00:00
Maurice ef77b312fa 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-22 21:30:35 +00:00
Maurice 4641788fc9 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-22 21:30:35 +00:00
Maurice d24db65cb8 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-22 21:30:26 +00:00
Maurice 123e27643d 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-22 21:30:26 +00:00
Maurice b64da26651 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-22 21:30:12 +00:00
github-actions[bot] d80577480c chore: bump version to 4.3.4 [skip ci] 2026-10-21 21:08:51 +00:00
1932 changed files with 202179 additions and 9010 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 4.3.3
version: 4.3.4
description: Minimal Helm chart for TREK app
appVersion: "4.3.3"
appVersion: "4.3.4"
+15
View File
@@ -0,0 +1,15 @@
import { devices, test } from '@playwright/test';
import { checkPageSnapshot } from './support/pageSnapshotFlow';
// The Studio's page image for the assistant, drawn in WebKit. playwright.config.ts
// has no WebKit project; the engine comes from this file-level device, as in
// e2e/urlbar-scroll-1809.spec.ts. Safari is why the rasteriser is
// modern-screenshot: it decodes images in a foreignObject late, and a page
// drawn too early comes out blank. page.route does not see a request the
// service worker answers, so it is blocked.
test.use({ ...devices['Desktop Safari'], serviceWorkers: 'block' });
test('Studio page snapshot: WebKit draws a seeded spread to a JPEG', async ({ page }) => {
await checkPageSnapshot(page);
});
+13
View File
@@ -0,0 +1,13 @@
import { test } from '@playwright/test';
import { checkPageSnapshot } from './support/pageSnapshotFlow';
// The Studio's page image for the assistant, drawn in Chromium. The same flow
// runs in WebKit from page-snapshot-webkit.spec.ts, the engine with the most
// to get wrong about drawing a page through an SVG foreignObject.
// page.route does not see a request the service worker answers, so it is blocked.
test.use({ serviceWorkers: 'block' });
test('Studio page snapshot: Chromium draws a seeded spread to a JPEG', async ({ page }) => {
await checkPageSnapshot(page);
});
+93
View File
@@ -0,0 +1,93 @@
import type { APIRequestContext, Browser, Page } from '@playwright/test';
import { devices, expect, test } from '@playwright/test';
import { E2E_STATE_FILE } from '../playwright.config';
// A stay booked somewhere else shows in the open planner without a reload. The
// assistant books through its tools, and a stay it booked showed in the planner
// only after a reload: the server told every tab, the client dropped the news.
// Here a second session (a browser context of its own, so the open planner's
// socket is not the one that asked) books the stay over the API, and the planner
// has to show it on its own, on the desktop and on the phone.
/** A trip with three dated days, and their ids in order. */
async function seedTrip(api: APIRequestContext, title: string) {
const created = await api.post('/api/trips', {
data: { title, start_date: '2099-07-01', end_date: '2099-07-03' },
});
expect(created.ok(), 'seed trip').toBeTruthy();
const body = await created.json();
const tripId: number = body.trip?.id ?? body.id;
const days = await (await api.get(`/api/trips/${tripId}/days`)).json();
const dayIds: number[] = (days.days ?? days).map((day: { id: number }) => day.id);
expect(dayIds.length, 'the trip has its three days').toBe(3);
return { tripId, dayIds };
}
/** Opens the planner and waits until its socket joined the trip's room, so no event can slip past it. */
async function openPlanner(page: Page, tripId: number) {
const joined = new Promise<void>((resolve) => {
page.on('websocket', (ws) => {
ws.on('framereceived', (frame) => {
if (String(frame.payload).includes('"joined"')) resolve();
});
});
});
await page.goto(`/trips/${tripId}`);
await joined;
}
/** Books a hotel from the first to the second day, from a session of its own. */
async function bookElsewhere(browser: Browser, tripId: number, dayIds: number[], hotel: string) {
const other = await browser.newContext({ storageState: E2E_STATE_FILE });
try {
const place = await other.request.post(`/api/trips/${tripId}/places`, {
data: { name: hotel, lat: 52.5306, lng: 13.3846 },
});
expect(place.ok(), 'the hotel place').toBeTruthy();
const placeBody = await place.json();
const stay = await other.request.post(`/api/trips/${tripId}/accommodations`, {
data: { place_id: placeBody.place?.id ?? placeBody.id, start_day_id: dayIds[0], end_day_id: dayIds[1] },
});
expect(stay.ok(), 'the stay').toBeTruthy();
} finally {
await other.close();
}
}
test('desktop: a stay booked in another session shows in the day plan without a reload', async ({ page, browser }) => {
const hotel = `Mercure Hotel ${Date.now()}`;
const { tripId, dayIds } = await seedTrip(page.request, `Stay live ${Date.now()}`);
await openPlanner(page, tripId);
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 });
await bookElsewhere(browser, tripId, dayIds, hotel);
// The day head names the hotel of the night, read from the planner's stays.
await expect(page.locator('[data-dp="day-pill"]', { hasText: hotel }).first()).toBeVisible({ timeout: 10_000 });
});
test('phone: a stay booked in another session shows on the day plan without a reload', async ({ browser }) => {
const pixel = devices['Pixel 7'];
const phone = await browser.newContext({
viewport: pixel.viewport,
userAgent: pixel.userAgent,
deviceScaleFactor: pixel.deviceScaleFactor,
isMobile: pixel.isMobile,
hasTouch: pixel.hasTouch,
storageState: E2E_STATE_FILE,
});
try {
const page = await phone.newPage();
const hotel = `Mercure Hotel ${Date.now()}`;
const { tripId, dayIds } = await seedTrip(page.request, `Stay live phone ${Date.now()}`);
await openPlanner(page, tripId);
await expect(page.locator('button[aria-current="true"]')).toBeVisible({ timeout: 30_000 });
await bookElsewhere(browser, tripId, dayIds, hotel);
// The first day's plan carries a chip for the hotel checked into that night.
await expect(page.locator('button', { hasText: hotel }).first()).toBeVisible({ timeout: 10_000 });
} finally {
await phone.close();
}
});
+53
View File
@@ -0,0 +1,53 @@
import { expect, type Page } from '@playwright/test';
import type { SeededSnapshot } from './pageSnapshotHarness';
/** The map tile from another server that the harness puts on the sheet. */
const TILE = 'https://tile.openstreetmap.org/5/16/10.png?e2e=page-snapshot';
/**
* The page snapshot the Studio sends the assistant, taken in a real browser
* (journey-studio.md 3.1): the harness module is loaded into the running app
* through the dev server, draws a seeded spread with the editor's SpreadView
* and rasterises it with modern-screenshot. The image has to be a non-empty
* JPEG at the size the Studio promises the server, and the map tile from
* another server on the sheet must never have been fetched for it. Every
* request to OpenStreetMap's tile servers is answered here, which keeps the
* run off the network, but only the harness's own tile counts: the sheet's
* <img> asks for it as an image, anything else would be the rasteriser
* reaching for it. Its query string sets it apart from the tiles the app
* itself may prefetch from the same servers. The specs block the service
* worker, whose tile cache would answer before the route sees a request.
*/
export async function checkPageSnapshot(page: Page): Promise<void> {
const tileFetches: string[] = [];
await page.route(/^https:\/\/(?:[a-c]\.)?tile\.openstreetmap\.org\//, (route) => {
const request = route.request();
const kind = request.resourceType();
if (request.url().startsWith(TILE) && kind !== 'image') tileFetches.push(kind);
return route.fulfill({ status: 204, headers: { 'access-control-allow-origin': '*' } });
});
await page.goto('/dashboard');
const shot = await page.evaluate(
async ({ harness, tile }: { harness: string; tile: string }): Promise<SeededSnapshot | null> => {
const mod = (await import(/* @vite-ignore */ harness)) as {
snapshotSeededSpread: (tileUrl: string) => Promise<SeededSnapshot | null>;
};
return mod.snapshotSeededSpread(tile);
},
{ harness: '/e2e/support/pageSnapshotHarness.ts', tile: TILE }
);
expect(shot, 'the spread was drawn and came out under the size limit').not.toBeNull();
if (!shot) return;
expect(shot.type).toBe('image/jpeg');
expect(shot.head).toEqual([0xff, 0xd8, 0xff]);
expect(shot.bytes).toBeGreaterThan(1_000);
// A double spread at 1280 pixels on its long edge, and the file says so too.
expect(shot.width).toBe(1280);
expect(shot.height).toBe(640);
expect(shot.decodedWidth).toBe(1280);
expect(shot.decodedHeight).toBe(640);
expect(shot.drawnPhotoIds).toEqual([]);
expect(tileFetches, 'the tile from another server is left out before anything is fetched').toEqual([]);
}
+85
View File
@@ -0,0 +1,85 @@
import { bookPageSetupForPreset, bookShapeElementSchema, bookTextElementSchema, type BookSpread } from '@trek/shared';
import { createElement } from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { SpreadView } from '../../src/components/Studio/SpreadView';
import { sheetSizePx, snapshotSpread } from '../../src/components/Studio/pageSnapshot';
/**
* Loaded by the page snapshot flows inside the running app, where the dev
* server serves it as a module: draws a seeded spread with the editor's own
* SpreadView and turns it into the JPEG the Studio sends the assistant, so a
* real Chromium and a real WebKit prove the rasteriser works end to end. A map
* tile from another server sits on the sheet and must be left out; the flow
* names the tile and watches its server.
*/
export interface SeededSnapshot {
type: string;
bytes: number;
/** The first bytes of the file: a JPEG starts FF D8 FF. */
head: number[];
width: number;
height: number;
/** The image decoded again, as the browser reads it. */
decodedWidth: number;
decodedHeight: number;
drawnPhotoIds: number[];
}
function seededSpread(): BookSpread {
return {
id: 'sp-seeded',
role: 'inner',
background: '#f4efe6',
elements: [
bookShapeElementSchema.parse({ id: 'shape', kind: 'shape', frame: { x: 20, y: 20, w: 160, h: 90 } }),
bookTextElementSchema.parse({
id: 'title',
kind: 'text',
frame: { x: 230, y: 30, w: 160, h: 30 },
text: 'Lisbon',
}),
],
parked: [],
entryId: null,
};
}
export async function snapshotSeededSpread(tileUrl: string): Promise<SeededSnapshot | null> {
const page = bookPageSetupForPreset('square-210');
const spread = seededSpread();
const size = sheetSizePx(spread, page);
// Off screen as the Studio's stage is; the sheet inside it is what is drawn.
const host = document.createElement('div');
host.style.cssText = 'position:fixed;top:0;inset-inline-start:-20000px';
const sheet = document.createElement('div');
sheet.style.cssText = `position:relative;width:${size.width}px;height:${size.height}px`;
host.appendChild(sheet);
document.body.appendChild(host);
const root = createRoot(sheet);
flushSync(() => root.render(createElement(SpreadView, { spread, page, print: true })));
const tile = document.createElement('img');
tile.src = tileUrl;
sheet.firstElementChild?.appendChild(tile);
try {
const shot = await snapshotSpread(sheet, size, new Set());
if (!shot) return null;
const head = [...new Uint8Array(await shot.image.slice(0, 3).arrayBuffer())];
const bitmap = await createImageBitmap(shot.image);
return {
type: shot.image.type,
bytes: shot.image.size,
head,
width: shot.width,
height: shot.height,
decodedWidth: bitmap.width,
decodedHeight: bitmap.height,
drawnPhotoIds: shot.drawnPhotoIds,
};
} finally {
root.unmount();
host.remove();
}
}
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "4.3.3",
"version": "4.3.4",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
@@ -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",
@@ -67,6 +68,7 @@
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "5.24.0",
"modern-screenshot": "4.7.0",
"plyr": "^3.8.4",
"react": "^19.3.0",
"react-dom": "^19.3.0",
+10 -10
View File
@@ -1,20 +1,20 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 858,
"@typescript-eslint/no-misused-promises": 854,
"no-console": 88,
"complexity": 229,
"complexity": 226,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 133,
"max-params": 35,
"trek/no-swallowed-catch": 131,
"max-params": 33,
"trek/store-without-selector": 67,
"react-hooks/refs": 557,
"react-hooks/set-state-in-effect": 196,
"@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 120,
"react-hooks/set-state-in-effect": 195,
"@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 126,
"react-hooks/immutability": 28,
"max-lines-per-function": 133,
"max-lines-per-function": 132,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
@@ -35,11 +35,11 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1943,
"@typescript-eslint/no-non-null-assertion": 1932,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
"trek/no-window-globals": 332,
"trek/no-window-globals": 331,
"trek/no-swallowed-catch": 14,
"react-hooks/globals": 5,
"react-hooks/refs": 22,
+31 -7
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"],
@@ -206,8 +226,7 @@
"src/components/Collections/CollectionPicker.tsx",
"src/components/Collections/CollectionPlaceDetail.tsx",
"src/components/Collections/ListEditorModal.tsx",
"src/components/Collections/MSaveToCollectionSheet.tsx",
"src/components/Collections/StatusBadge.tsx"
"src/components/Collections/MSaveToCollectionSheet.tsx"
],
"mobile": [
"src/mobile/screens/collections/MCollAddSheet.tsx",
@@ -245,8 +264,8 @@
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
{
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm",
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts"],
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm, useExpenseLinks",
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts", "src/components/Budget/useExpenseLinks.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
@@ -658,6 +677,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"],
@@ -976,7 +1001,6 @@
"src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": "Phone action sheet for a pool place that calls planner handlers and the shared placeToSaveTarget.",
"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/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).",
-16
View File
@@ -400,8 +400,6 @@
"src/components/Journey/moodConfig.ts",
"src/components/Journey/photoZoom.test.ts",
"src/components/Journey/photoZoom.ts",
"src/components/Journey/stripMarkdown.test.ts",
"src/components/Journey/stripMarkdown.ts",
"src/components/Journey/useConnectedPhotoProviders.test.tsx",
"src/components/Journey/useConnectedPhotoProviders.ts",
"src/components/Journey/useEntryPhotoOrder.test.ts",
@@ -729,8 +727,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",
@@ -783,7 +779,6 @@
"src/components/Roadtrip/AutomaticDayStop.test.tsx",
"src/components/Roadtrip/AutomaticDayStop.tsx",
"src/components/Roadtrip/ChargingInfo.test.tsx",
"src/components/Roadtrip/ChargingInfo.tsx",
"src/components/Roadtrip/DayWindowFields.test.tsx",
"src/components/Roadtrip/DayWindowFields.tsx",
"src/components/Roadtrip/EndDayControl.test.tsx",
@@ -975,19 +970,13 @@
"src/components/Studio/TravelElements.tsx",
"src/components/Studio/TravelPreview.tsx",
"src/components/Studio/TrimField.tsx",
"src/components/Studio/applyTemplate.ts",
"src/components/Studio/autoLayout.ts",
"src/components/Studio/bookFontFaces.ts",
"src/components/Studio/bookFonts.ts",
"src/components/Studio/bookIds.ts",
"src/components/Studio/bookRender.ts",
"src/components/Studio/bookSheets.ts",
"src/components/Studio/bookTemplates.data.ts",
"src/components/Studio/countryShapes.ts",
"src/components/Studio/entryText.ts",
"src/components/Studio/flags.ts",
"src/components/Studio/folioColour.ts",
"src/components/Studio/grids.ts",
"src/components/Studio/iconLibrary.ts",
"src/components/Studio/mapSources.ts",
"src/components/Studio/mapTiles.ts",
@@ -997,13 +986,10 @@
"src/components/Studio/printSheets.ts",
"src/components/Studio/resolveBindings.ts",
"src/components/Studio/roadRoute.ts",
"src/components/Studio/savedLayouts.ts",
"src/components/Studio/shapes.ts",
"src/components/Studio/spreadFile.ts",
"src/components/Studio/studioClipboard.ts",
"src/components/Studio/studioUpload.ts",
"src/components/Studio/templates.ts",
"src/components/Studio/travelRefresh.ts",
"src/components/Studio/useBookPresence.ts",
"src/components/Studio/useBookStore.ts",
"src/components/Studio/useSpreadInteraction.ts",
@@ -2116,7 +2102,6 @@
"tests/unit/studio/entryText.test.ts",
"tests/unit/studio/flags.test.ts",
"tests/unit/studio/folioColour.test.ts",
"tests/unit/studio/grids.test.ts",
"tests/unit/studio/iconElement.test.tsx",
"tests/unit/studio/inspector.test.tsx",
"tests/unit/studio/inspectorTravel.test.tsx",
@@ -2140,7 +2125,6 @@
"tests/unit/studio/studioClipboardLayouts.test.ts",
"tests/unit/studio/studioStore.spreads.test.ts",
"tests/unit/studio/studioUpload.test.ts",
"tests/unit/studio/templates.test.ts",
"tests/unit/studio/travelRefresh.test.ts",
"tests/unit/studio/travelStops.test.tsx",
"tests/unit/studio/trimField.test.tsx",
+10 -5
View File
@@ -44,7 +44,8 @@
*
* The en keys nothing reaches are a ratchet. Besides src/, the search for
* references reads server/src (system notice and notification channel keys
* the server sends, *Key properties in its registries) and plugin-sdk/src, so
* the server sends, *Key properties in its registries), plugin-sdk/src and
* shared/src/book (the Studio's layout template names), so
* a key the server uses does not read as dead; those references only count as
* reached, they are not held to the rules above. The unused keys per en
* domain file may not rise above scripts/i18n-unused-baseline.json, and an
@@ -69,11 +70,15 @@ const UNUSED_BASELINE = join(HERE, 'i18n-unused-baseline.json');
/**
* Where else a translation key can be named: the server sends keys for the
* client to translate (system notices, notification channels), and the plugin
* SDK names the ones plugin surfaces use. Read only to find which en keys are
* reached.
* client to translate (system notices, notification channels), the plugin
* SDK names the ones plugin surfaces use, and the shared book engine names
* its layout templates. Read only to find which en keys are reached.
*/
export const REFERENCE_ROOTS = [join(HERE, '..', '..', 'server', 'src'), join(HERE, '..', '..', 'plugin-sdk', 'src')];
export const REFERENCE_ROOTS = [
join(HERE, '..', '..', 'server', 'src'),
join(HERE, '..', '..', 'plugin-sdk', 'src'),
join(HERE, '..', '..', 'shared', 'src', 'book'),
];
/** How many en keys a template may reach and still pass without an entry below. */
export const MAX_IMPLICIT_MATCHES = 30;
+6 -6
View File
@@ -1,8 +1,8 @@
{
"admin.ts": 126,
"atlas.ts": 10,
"atlas.ts": 9,
"backup.ts": 10,
"budget.ts": 40,
"budget.ts": 36,
"categories.ts": 1,
"collab.ts": 9,
"collection.ts": 35,
@@ -14,7 +14,7 @@
"files.ts": 4,
"help.ts": 3,
"inspector.ts": 4,
"journey.ts": 69,
"journey.ts": 65,
"login.ts": 1,
"map.ts": 5,
"memories.ts": 8,
@@ -29,13 +29,13 @@
"places.ts": 10,
"planner.ts": 53,
"reservations.ts": 21,
"roadtrip.ts": 38,
"settings.ts": 40,
"roadtrip.ts": 36,
"settings.ts": 37,
"share.ts": 7,
"shared.ts": 2,
"storage.ts": 16,
"system_notice.ts": 32,
"todo.ts": 4,
"todo.ts": 3,
"tours.ts": 12,
"trip.ts": 10,
"undo.ts": 1,
+1 -2
View File
@@ -12,7 +12,6 @@
"src/components/Collections/CollectionPlaceDetail.tsx": 1,
"src/components/Collections/ListEditorModal.tsx": 1,
"src/components/Collections/MSaveToCollectionSheet.tsx": 3,
"src/components/Collections/StatusBadge.tsx": 1,
"src/components/Dawarich/DawarichAcceptDialog.tsx": 3,
"src/components/Dawarich/DawarichAtlasDialog.tsx": 3,
"src/components/Dawarich/DawarichSuggestionsPanel.tsx": 1,
@@ -56,6 +55,6 @@
"src/hooks/useWebPush.ts": 1,
"src/repo/pluginPoiRepo.ts": 1,
"src/store/slices/budgetSlice.ts": 1,
"src/store/studioStore.ts": 2,
"src/store/studioStore.ts": 1,
"src/store/vacayStore.ts": 1
}
+7 -1
View File
@@ -31,11 +31,15 @@ export async function isFormatted(path) {
if (!options) throw new RatchetError(`no Prettier config applies to ${path}`);
try {
return await prettier.check(readText(path), { ...options, filepath: path });
} catch {
} catch (err) {
stopped.set(path, String(err instanceof Error ? err.message : err).split('\n')[0]);
return false;
}
}
/** Why Prettier gave up on a file, by its path, so the failure line can say it was not about style. */
const stopped = new Map();
/** The files under root that Prettier would rewrite, keyed by their path from root. */
export async function unformatted(root) {
const found = [];
@@ -74,6 +78,8 @@ export async function check({
if (list) for (const key of found) log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`);
for (const key of fresh) {
error(`FAIL ${key} is not formatted. Run: npx prettier --write ${key}`);
const why = stopped.get(join(root, key));
if (why) error(` Prettier could not format it: ${why}`);
}
for (const key of stale) {
error(`FAIL ${key} is listed in scripts/format-baseline.json but is formatted now or gone.`);
+1 -2
View File
@@ -6,7 +6,6 @@
"components/Collab/CollabChatEmojiPicker.tsx": 3,
"components/Collab/CollabChatReactionBadge.tsx": 3,
"components/Collab/CollabChatReactionMenu.tsx": 1,
"components/Collab/CollabPolls.tsx": 1,
"components/Collections/CollectionPlaceDetail.tsx": 2,
"components/Files/FileManagerAvatarChip.tsx": 3,
"components/Journey/JourneyDayJump.tsx": 1,
@@ -59,7 +58,7 @@
"components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/StudioSidebar.tsx": 3,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
+3 -5
View File
@@ -4,10 +4,10 @@
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Budget/CostsPanel.tsx": 1804,
"src/components/Map/MapView.tsx": 1401,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2293,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
@@ -17,8 +17,6 @@
"src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/autoLayout.ts": 1691,
"src/components/Studio/bookTemplates.data.ts": 1309,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
-3
View File
@@ -9,7 +9,6 @@
"src/components/Collab/CollabChat.helpers.ts": 1,
"src/components/Collab/CollabChat.tsx": 3,
"src/components/Collab/CollabChatEmojiPicker.tsx": 5,
"src/components/Collab/CollabChatMessageText.tsx": 2,
"src/components/Collab/CollabChatMessages.tsx": 4,
"src/components/Collab/CollabChatReactionBadge.tsx": 1,
"src/components/Collab/CollabChatReactionMenu.tsx": 2,
@@ -85,7 +84,6 @@
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
"src/components/Studio/StudioCanvas.tsx": 1,
"src/components/Studio/StudioInspector.tsx": 1,
"src/components/Studio/autoLayout.ts": 1,
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2,
@@ -145,7 +143,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 -4
View File
@@ -105,10 +105,8 @@
"components/Studio/StudioInspector.tsx": 2,
"components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/StudioTravelPanel.tsx": 1,
"components/Studio/TravelElements.tsx": 5,
"components/Studio/printSheets.ts": 5,
"components/Studio/templates.ts": 3,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
"components/SystemNotices/SystemNoticeModal.tsx": 107,
@@ -154,7 +152,7 @@
"mobile/components/MToastHost.tsx": 2,
"mobile/components/MUserPickerList.tsx": 1,
"mobile/screens/admin/MAdmin.tsx": 9,
"mobile/screens/admin/MAdminAddonManager.tsx": 22,
"mobile/screens/admin/MAdminAddonManager.tsx": 9,
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
@@ -310,7 +308,6 @@
"pages/TripPlannerPage.tsx": 7,
"pages/VacayPage.tsx": 13,
"pages/atlas/useAtlas.ts": 2,
"pages/collections/collectionsModel.ts": 1,
"pages/collections/useCollections.ts": 1,
"pages/sharedTrip/SharedChrome.tsx": 2,
"pages/sharedTrip/SharedMap.tsx": 1,
@@ -44,7 +44,6 @@
"components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 6,
"components/Studio/autoLayout.ts": 50,
"components/Studio/peerColour.ts": 1,
"components/Studio/printSheets.ts": 4,
"components/Trips/TripMemberAvatar.tsx": 2,
+8
View File
@@ -65,6 +65,8 @@ const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotifica
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
// The tour keep dialog a day plan row opens; it loads only once a tour is to be kept.
const TourSaveToCollectionHost = lazyWithRetry(() => import('./components/Tours/collection/TourSaveToCollectionHost'))
// 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
@@ -408,6 +410,12 @@ export default function App() {
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
{!hideAuthedWidgets && keepingTour && <ErrorBoundary boundaryId="widget:keep-tour" fallback={null}><Suspense fallback={null}><TourSaveToCollectionHost /></Suspense></ErrorBoundary>}
{/* 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
+168
View File
@@ -0,0 +1,168 @@
/**
* FE-AI-API-001 to FE-AI-API-008
*
* 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. A
* journey's chat reaches the same calls under the journey.
*/
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({});
});
it('FE-AI-API-008: a journey scope reaches the journey routes, a trip scope the trip routes', async () => {
const journey = { kind: 'journey', journeyId: 12 } as const;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => streamResponse('data: [DONE]\n\n'));
const signal = new AbortController().signal;
await assistantApi.chat(journey, BODY, signal);
await assistantApi.decide(journey, 7, { locale: 'en', timeZone: 'UTC', decisions: [] }, signal);
await assistantApi.chat({ kind: 'trip', tripId: 4 }, BODY, signal);
expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
'/api/journeys/12/assistant/chat',
'/api/journeys/12/assistant/threads/7/decisions',
'/api/trips/4/assistant/chat',
]);
const hits: string[] = [];
server.use(
http.get('/api/journeys/12/assistant/threads', () => {
hits.push('threads');
return HttpResponse.json({ threads: [] });
}),
http.post('/api/journeys/12/assistant/tool-calls/:id/undo', () => {
hits.push('undo');
return HttpResponse.json({ undone: true });
}),
http.post('/api/journeys/12/assistant/stop', () => {
hits.push('stop');
return HttpResponse.json({ success: true });
})
);
await assistantApi.threads(journey);
await assistantApi.undo(journey, 'c1');
await assistantApi.stop(journey);
expect(hits).toEqual(['threads', 'undo', 'stop']);
});
});
+229
View File
@@ -0,0 +1,229 @@
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 AssistantRenderUploadResponse,
type AssistantScope,
type AssistantShareRequest,
type AssistantTakeProposalRequest,
type AssistantThreadListResponse,
type AssistantThreadResponse,
type AssistantUndoResponse,
type BookDesignDraftResponse,
type CollabMessageResponse,
type SuccessResponse,
type UiStreamChunk,
} from '@trek/shared';
import apiClient, { postMultipart } from './client';
import { parseUiMessageStream } from './uiMessageStream';
import { getSocketId } from './websocket';
/**
* The 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`;
/**
* What a conversation call is about: a trip's id (the planner's chat), or a
* scope, which also reaches a journey's chat in TREK Studio. Threads, turns,
* decisions, undo and stop exist under both; files, found places, proposals
* and sharing belong to a trip and take its id.
*/
export type AssistantTarget = number | AssistantScope;
function scopeBase(target: AssistantTarget): string {
if (typeof target === 'number') return tripBase(target);
return target.kind === 'trip' ? tripBase(target.tripId) : `/journeys/${target.journeyId}/assistant`;
}
/** Where the Studio uploads a page a running turn of a journey's chat asked for. */
const renderPath = (journeyId: number, threadId: number, requestId: string) =>
`/journeys/${journeyId}/assistant/threads/${threadId}/renders/${encodeURIComponent(requestId)}`;
/**
* 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: (target: AssistantTarget): Promise<AssistantThreadListResponse> =>
apiClient.get(`${scopeBase(target)}/threads`).then((r) => r.data),
thread: (target: AssistantTarget, threadId: number): Promise<AssistantThreadResponse> =>
apiClient.get(`${scopeBase(target)}/threads/${threadId}`).then((r) => r.data),
deleteThread: (target: AssistantTarget, threadId: number): Promise<SuccessResponse> =>
apiClient.delete(`${scopeBase(target)}/threads/${threadId}`).then((r) => r.data),
undo: (target: AssistantTarget, toolCallId: string): Promise<AssistantUndoResponse> =>
apiClient.post(`${scopeBase(target)}/tool-calls/${encodeURIComponent(toolCallId)}/undo`, {}).then((r) => r.data),
// Ends this user's running turn, wherever it runs (another tab included).
stop: (target: AssistantTarget): Promise<SuccessResponse> =>
apiClient.post(`${scopeBase(target)}/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),
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
takeProposal: (
tripId: number,
threadId: number,
body: AssistantTakeProposalRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/proposals`, 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: (target: AssistantTarget, body: AssistantChatRequest, signal: AbortSignal) =>
openStream(`${scopeBase(target)}/chat`, body, signal),
decide: (target: AssistantTarget, threadId: number, body: AssistantDecisionsRequest, signal: AbortSignal) =>
openStream(`${scopeBase(target)}/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 Studio page a running turn asked for, drawn by this tab: the image and what it shows, as multipart.
uploadRender: (
journeyId: number,
threadId: number,
requestId: string,
form: FormData,
signal: AbortSignal
): Promise<AssistantRenderUploadResponse> =>
postMultipart(renderPath(journeyId, threadId, requestId), form, { signal }),
// A whole-book design draft, for a page of it a running turn asked the Studio to draw.
bookDesign: (journeyId: number, designId: string): Promise<BookDesignDraftResponse> =>
apiClient.get(`/journeys/${journeyId}/book-designs/${encodeURIComponent(designId)}`).then((r) => r.data),
};
/** 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),
};
+21
View File
@@ -0,0 +1,21 @@
import type { BookDesignSelectionRequest, BookDesignSelectionResponse } from '@trek/shared';
import apiClient from './client';
/**
* The assistant's whole-book design drafts as the Studio chat's proposal card
* narrows them: the pages of a draft its apply writes. The draft itself is
* read through `assistantApi.bookDesign`, which the Studio also draws a page
* of a draft from. Online only, like the assistant: a draft lives on the
* server for a few hours and nowhere else.
*/
export const bookDesignApi = {
selection: (
journeyId: number,
designId: string,
body: BookDesignSelectionRequest
): Promise<BookDesignSelectionResponse> =>
apiClient
.put(`/journeys/${journeyId}/book-designs/${encodeURIComponent(designId)}/selection`, body)
.then((r) => r.data),
};
+150
View File
@@ -0,0 +1,150 @@
/**
* FE-AI-STREAM-001 to FE-AI-STREAM-011
*
* The parser reads the server's assistant stream, so its contract is the
* shared fixture: the same file the server's writer is tested against. What
* the network does to that stream (cutting it at any byte, CRLF from a proxy,
* keep-alive comments) must not change what comes out.
*/
import { uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { MAX_EVENT_CHARS, parseUiMessageStream, STALL_MS } from './uiMessageStream';
const FIXTURE = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), '../../../shared/src/assistant/__fixtures__/ui-stream.v1.txt'),
'utf8'
);
/** A response body that hands out the given pieces one read at a time. */
function bodyOf(pieces: (string | Uint8Array)[]) {
const encoder = new TextEncoder();
let i = 0;
const cancel = vi.fn(async () => {});
const body = {
getReader: () => ({
read: async () => {
if (i >= pieces.length) return { done: true, value: undefined };
const piece = pieces[i++];
return { done: false, value: typeof piece === 'string' ? encoder.encode(piece) : piece };
},
cancel,
}),
} as unknown as ReadableStream<Uint8Array>;
return { body, cancel };
}
async function collect(body: ReadableStream<Uint8Array>): Promise<UiStreamChunk[]> {
const chunks: UiStreamChunk[] = [];
for await (const chunk of parseUiMessageStream(body)) chunks.push(chunk);
return chunks;
}
/** The fixture's chunks as plain JSON, the way a reader of the file sees them. */
function fixtureChunks(): UiStreamChunk[] {
return FIXTURE.split('\n\n')
.map((event) => event.replace(/^data: /, '').trim())
.filter((data) => data && data !== '[DONE]')
.map((data) => uiStreamChunkSchema.parse(JSON.parse(data)));
}
describe('parseUiMessageStream', () => {
it('FE-AI-STREAM-001: reads the shared fixture into its chunks, in order', async () => {
const chunks = await collect(bodyOf([FIXTURE]).body);
expect(chunks).toEqual(fixtureChunks());
expect(chunks.map((c) => c.type)).toContain('tool-approval-request');
expect(chunks[chunks.length - 1]).toEqual({ type: 'finish', finishReason: 'tool-calls' });
});
it('FE-AI-STREAM-002: gives the same chunks when every byte arrives in its own read', async () => {
const bytes = new TextEncoder().encode(FIXTURE);
const pieces = Array.from(bytes, (b) => new Uint8Array([b]));
expect(await collect(bodyOf(pieces).body)).toEqual(fixtureChunks());
});
it('FE-AI-STREAM-003: reads CRLF line ends, also when a CR and its LF arrive apart', async () => {
const crlf = FIXTURE.replace(/\n/g, '\r\n');
const cut = crlf.indexOf('\r\n') + 1;
expect(await collect(bodyOf([crlf.slice(0, cut), crlf.slice(cut)]).body)).toEqual(fixtureChunks());
});
it('FE-AI-STREAM-004: skips keep-alive comments and fields other than data', async () => {
const stream = [
': keepalive\n\n',
'event: message\nid: 1\ndata: {"type":"start-step"}\n\n',
': keepalive\n\n',
'data: [DONE]\n\n',
].join('');
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'start-step' }]);
});
it('FE-AI-STREAM-005: skips chunk types this client does not know, and torn JSON', async () => {
const stream = [
'data: {"type":"reasoning-delta","delta":"hm"}\n\n',
'data: {"type":"text-delta","id":"t"\n\n',
'data: {"type":"text-delta","id":"t","delta":"ok"}\n\n',
'data: [DONE]\n\n',
].join('');
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'ok' }]);
});
it('FE-AI-STREAM-006: reads an unknown error code as generic', async () => {
const stream = 'data: {"type":"error","errorText":"quota_exploded"}\n\ndata: [DONE]\n\n';
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'error', errorText: 'generic' }]);
});
it('FE-AI-STREAM-007: joins the lines of a multi-line data field', async () => {
const stream = 'data: {"type":"text-delta",\ndata: "id":"t","delta":"x"}\n\ndata: [DONE]\n\n';
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'x' }]);
});
it('FE-AI-STREAM-008: stops at [DONE] and lets go of the connection', async () => {
const { body, cancel } = bodyOf([
'data: {"type":"start-step"}\n\ndata: [DONE]\n\ndata: {"type":"finish-step"}\n\n',
]);
expect(await collect(body)).toEqual([{ type: 'start-step' }]);
expect(cancel).not.toHaveBeenCalled();
});
it('FE-AI-STREAM-009: a stream that closes before [DONE] broke off, after the chunks it finished', async () => {
const { body, cancel } = bodyOf(['data: {"type":"start-step"}\n\ndata: {"type":"finish-st']);
const seen: UiStreamChunk[] = [];
const read = async () => {
for await (const chunk of parseUiMessageStream(body)) seen.push(chunk);
};
await expect(read()).rejects.toThrow('broke off');
expect(seen).toEqual([{ type: 'start-step' }]);
expect(cancel).not.toHaveBeenCalled();
});
it('FE-AI-STREAM-011: a stream that stays silent past the stall limit fails and lets go', async () => {
vi.useFakeTimers();
try {
const cancel = vi.fn(async () => {});
const body = {
getReader: () => ({ read: () => new Promise(() => {}), cancel }),
} as unknown as ReadableStream<Uint8Array>;
const failed = expect(collect(body)).rejects.toThrow('stalled');
await vi.advanceTimersByTimeAsync(STALL_MS);
await failed;
expect(cancel).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('FE-AI-STREAM-010: refuses an event past the size limit, and a consumer leaving early cancels the read', async () => {
const huge = bodyOf([`data: ${'x'.repeat(MAX_EVENT_CHARS + 1)}`]);
await expect(collect(huge.body)).rejects.toThrow('too large');
expect(huge.cancel).toHaveBeenCalledTimes(1);
const early = bodyOf([FIXTURE]);
for await (const chunk of parseUiMessageStream(early.body)) {
expect(chunk.type).toBe('start');
break;
}
expect(early.cancel).toHaveBeenCalledTimes(1);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { UI_STREAM_DONE, uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared';
/**
* Reads the assistant's server-sent events into typed chunks.
*
* The wire format is the `@trek/shared` stream contract: one `data: <json>`
* event per chunk, closed by `data: [DONE]`. Our own parser rather than the
* `assistant-stream` one, because that one drops `tool-approval-request`, the
* chunk a held change hangs on, and it would pull the chat runtime into every
* chunk that only needs the data layer.
*
* What it tolerates, because proxies and the network do these things: events
* split across reads at any byte, CRLF line ends, `:` keep-alive comments,
* and fields other than `data`. A chunk that does not match the contract,
* a type this client does not know among them, is skipped, so the server can
* add chunk types without breaking an older tab. An event longer than
* MAX_EVENT_CHARS ends the stream with an error instead of growing the buffer
* without bound.
*
* The server always closes with `[DONE]` and sends a keep-alive every 15
* seconds, so a stream that ends without the marker, or stays silent for
* STALL_MS, lost its server (a restart, a dropped proxy) and fails instead of
* leaving the chat waiting for an answer that will not come.
*/
export const MAX_EVENT_CHARS = 1024 * 1024;
export const STALL_MS = 45_000;
/** The next read, or a failure once nothing came for STALL_MS. */
async function readOrStall(reader: ReadableStreamDefaultReader<Uint8Array>) {
let timer: ReturnType<typeof setTimeout> | undefined;
const stalled = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('Assistant stream stalled')), STALL_MS);
});
try {
return await Promise.race([reader.read(), stalled]);
} finally {
clearTimeout(timer);
}
}
export async function* parseUiMessageStream(body: ReadableStream<Uint8Array>): AsyncGenerator<UiStreamChunk> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finished = false;
try {
for (;;) {
const { done, value } = await readOrStall(reader);
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
// A CR at the very end may be the first half of a CRLF; it waits for the next read.
const carry = !done && buffer.endsWith('\r') ? '\r' : '';
if (carry) buffer = buffer.slice(0, -1);
buffer = buffer.replace(/\r\n?/g, '\n');
let boundary = buffer.indexOf('\n\n');
while (boundary >= 0) {
const data = dataOf(buffer.slice(0, boundary));
buffer = buffer.slice(boundary + 2);
if (data === UI_STREAM_DONE) {
finished = true;
return;
}
const chunk = data === null ? null : chunkOf(data);
if (chunk) yield chunk;
boundary = buffer.indexOf('\n\n');
}
buffer += carry;
if (buffer.length > MAX_EVENT_CHARS) throw new Error('Assistant stream event too large');
// The connection closed before [DONE]: the server went away mid-answer.
if (done) {
finished = true;
throw new Error('Assistant stream broke off');
}
}
} finally {
// Leaving early (the consumer stopped, or an error) releases the connection.
if (!finished) {
try {
await reader.cancel();
} catch {
// The stream already failed; the error that brought us here is the one that counts.
}
}
}
}
/** The joined `data` lines of one event, or null for an event without any (a comment). */
function dataOf(event: string): string | null {
const lines: string[] = [];
for (const line of event.split('\n')) {
if (line === 'data' || line.startsWith('data:')) {
const value = line.slice(5);
lines.push(value.startsWith(' ') ? value.slice(1) : value);
}
}
return lines.length ? lines.join('\n') : null;
}
function chunkOf(data: string): UiStreamChunk | null {
let json: unknown;
try {
json = JSON.parse(data);
} catch {
// A torn or foreign event; the next one is still worth reading.
return null;
}
const parsed = uiStreamChunkSchema.safeParse(json);
return parsed.success ? parsed.data : null;
}
+58 -29
View File
@@ -43,9 +43,37 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'roadtripPreferences:changed',
// components/Roadtrip/useDayBoundaries reloads the shared boundaries.
'roadtripBoundary:changed',
// In-app notifications — hooks/useInAppNotificationListener
// In-app notifications: hooks/useInAppNotificationListener, which also keeps
// the bell's unread count in step (inAppNotificationStore).
'notification:new',
'notification:updated',
'notification:read-all',
// The same app-wide listener reads the display settings (settingsStore) and the
// tags (tripStore.reloadTags) again; both events carry names, never values.
'settings:updated',
'tags:changed',
// pages/dashboard/useDashboard reads the trip list again (desktop and phone).
'trips:changed',
// pages/atlas/useAtlas reads the atlas again (desktop and phone).
'atlas:changed',
// Trip roster: pages/tripPlanner/useTripRosterSync and the desktop packing panel
// (components/Packing/usePackingListPanel) read the members again. member:removed
// (it may be this user leaving the trip) and member:updated (a guest's new name on
// the rows that list people) are applied in the store and read back by the same
// hooks; both also move the balances below.
'member:added',
// Balances: components/Budget/useCostsLedger reads the settlement again on
// these and on the budget item events that move a split.
'budget:settlement-created',
'budget:settlement-updated',
'budget:settlement-deleted',
// Bags and who looks after a category: components/Packing/useBagTotalsPing and
// usePackingCategoryAssignees read them again, for both packing lists.
'packing:bag-created',
'packing:bag-updated',
'packing:bag-deleted',
'packing:bag-members-updated',
'packing:assignees',
// Bag weights (#2191) — content-free ping; components/Packing/usePackingListPanel
// and mobile/screens/trip/tabs/MPackingListTab re-read the bags on it. It carries
// no state to apply, which is the point: the totals are summed server-side across
@@ -72,6 +100,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'vacay:share-removed',
'vacay:shared-update',
// Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener)
// reads the open journey again, and leaves it once journey:deleted says it is gone.
// The journey list (pages/journey/useJourney) reads itself again on those two.
'journey:updated',
'journey:deleted',
'journey:trip:synced',
'journey:entry:created',
'journey:entry:updated',
@@ -106,35 +138,32 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'import:progress',
'import:done',
'import:error',
] as const satisfies readonly TrekWsEventName[]
/**
* Events the client deliberately does not act on today (state of the world
* when the registry landed — every one of these was already dropped by the
* old silent `default:` branches). Removing an entry means the event is now
* handled somewhere; ADDING an entry is a product decision that a new server
* event should have no client reaction — never add one just to silence the
* registry-parity test.
*/
export const IGNORED_WS_EVENTS = [
'assignment:participants',
'packing:reordered',
'packing:bag-created',
'packing:bag-updated',
'packing:bag-deleted',
'packing:bag-members-updated',
'packing:assignees',
'packing:template-applied',
'todo:assignees',
'budget:settlement-created',
'budget:settlement-updated',
'budget:settlement-deleted',
// Accommodations live in page-local planner state; the client refetches
// them off trip:updated date changes, never off these events.
// Stays live in page-local planner state, not in the trip store. A stay
// written elsewhere (the assistant, a fellow traveller, a plugin, a booking
// import) goes remoteEventHandler (STAY_REFRESH_EVENTS) -> accommodations:refresh
// -> components/Planner/useStaysRefresh, which reloads them once per burst in
// pages/tripPlanner/useTripPlanner (sidebar, map, the phone's day sheet) and
// components/Planner/useDayDetail (the desktop day panel).
'accommodation:created',
'accommodation:updated',
'accommodation:deleted',
'trip:deleted',
'member:added',
'member:removed',
] as const satisfies readonly TrekWsEventName[]
/**
* Events the client deliberately does not act on, each with the reason. Removing
* an entry means the event is now handled somewhere; ADDING one is a product
* decision that a server event should have no client reaction, never a way to
* silence the registry-parity test, which pins these keys to a list that only
* shrinks.
*/
export const IGNORED_WS_EVENTS: Partial<Record<TrekWsEventName, string>> = {
'todo:assignees':
'No screen shows who looks after a todo category (todoApi.getCategoryAssignees has no caller), so there is nothing to refresh.',
}
/**
* Events registered ahead of the write that sends them. The broadcast parity
* work gave every one of them a handler, so the list is empty; it stays as the
* place a future event registered before its sender goes, and only shrinks.
*/
export const AWAITING_SENDER_WS_EVENTS: readonly TrekWsEventName[] = []
+49 -278
View File
@@ -1,6 +1,6 @@
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
@@ -24,23 +24,20 @@ function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
}
function llmAddon(config: Record<string, unknown> = {}) {
function llmAddon(config: Record<string, unknown> = {}, enabled = true) {
return buildAddon({
id: 'llm_parsing',
name: 'AI Parsing',
description: 'Extract bookings from files',
icon: 'Sparkles',
type: 'integration',
enabled: true,
enabled,
config,
});
}
function modelsRoute(names: string[], seen?: (string | null)[]) {
return http.get('/api/admin/llm/local/models', ({ request }) => {
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
});
function aiAddon(id: string, name: string, enabled: boolean) {
return buildAddon({ id, name, icon: id === 'mcp' ? 'Terminal' : 'Sparkles', type: 'integration', enabled });
}
/** Every switch is labelled with the thing it controls, top-level tile or shelf
@@ -576,286 +573,55 @@ describe('AddonManager', () => {
expect(links.getAttribute('title')).not.toBe('No shared links yet');
});
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
render(<AddonManager />);
await screen.findByText('AI Parsing');
// The catalog key now exists, so the tile shows the translation rather than falling
// back to the English description the server sent.
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
render(<AddonManager />);
const label = await screen.findByText('Provider');
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
const tile = label.closest('article');
expect(tile).not.toBeNull();
expect(tile!.textContent).toContain('AI Parsing');
});
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
await screen.findByRole('button', { name: 'llama3:8b' });
expect(urls[0]).toBe('http://localhost:11434/v1');
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
const user = userEvent.setup();
let calls = 0;
it('FE-ADMIN-ADDON-020: the AI addons head the integration column as one tile with a switch each', async () => {
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1
? HttpResponse.json({ error: 'down' }, { status: 500 })
: HttpResponse.json({ models: [] });
}),
addonsRoute([
aiAddon('mcp', 'MCP', false),
llmAddon({}, false),
aiAddon('ai_assistant', 'AI Assistant', false),
buildAddon({ id: 'airtrail', name: 'AirTrail', type: 'integration', icon: 'Plane' }),
]),
);
render(<AddonManager />);
// The server's reason is shown, not axios' status line (#2649)
await screen.findByText('down');
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet — pull one below.');
expect(calls).toBe(2);
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
const switches = within(tile).getAllByRole('button').map((b) => b.getAttribute('aria-label'));
// In the tile's order, whatever order the server sent them in.
expect(switches).toEqual(['AI Assistant', 'AI Parsing', 'MCP']);
expect(within(tile).getByText('0/3')).toBeInTheDocument();
// Their own icons, keyed by id: the icon column of an install still says Sparkles or Terminal.
expect(tile.querySelector('svg.lucide-messages-square')).toBeInTheDocument();
expect(tile.querySelector('svg.lucide-scan-text')).toBeInTheDocument();
expect(tile.querySelector('svg.lucide-cable')).toBeInTheDocument();
// Nothing to set up while neither the assistant nor parsing is on.
expect(within(tile).queryByRole('button', { name: 'Set up AI' })).toBeNull();
// AirTrail is still its own tile in the same column.
expect(screen.getByText('AirTrail').closest('article')).not.toBe(tile);
});
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
it('FE-ADMIN-ADDON-021: with parsing on, the tile offers the settings dialog, which closes again', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
server.use(
addonsRoute([llmAddon({}), aiAddon('ai_assistant', 'AI Assistant', false), aiAddon('mcp', 'MCP', true)]),
http.get('/api/admin/ai/settings', () =>
HttpResponse.json({ policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }),
),
http.get('/api/admin/ai/connection', () => HttpResponse.json({ connection: null, prefill: null })),
http.get('/api/ai/status', () => HttpResponse.json({ error: 'not needed' }, { status: 404 })),
);
render(<AddonManager />);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
// A hand-typed base URL is used for the next lookup on blur
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
await user.tab();
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
// A gateway can stand in for Anthropic too, so the field stays (#2655).
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
expect(within(tile).getByText('2/3')).toBeInTheDocument();
await user.click(within(tile).getByRole('button', { name: 'Set up AI' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
expect(within(dialog).queryByRole('button', { name: 'AI Assistant' })).toBeNull();
const closes = within(dialog).getAllByRole('button', { name: 'Close' });
await user.click(closes[closes.length - 1]);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
const user = userEvent.setup();
let pulled: unknown = null;
let modelCalls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
await delay(150);
return new HttpResponse(
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
);
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('Pulling…');
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('no disk space');
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
expect(bodies[0]).toEqual({
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
});
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Failed to save');
});
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
// The model is trimmed before it is stored, the key is sent verbatim
expect(bodies[0]).toEqual({
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', vision: 'auto' },
});
});
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
expect(bodies[0]).toEqual({
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
});
});
it('FE-ADMIN-ADDON-038: whether the model reads images is auto, on or off, kept as stored and saved as chosen', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'off' })]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
// Stored off stays off; a cloud provider is told what Automatic means for it.
await user.click(await screen.findByRole('button', { name: 'No' }));
expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Yes' }));
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
expect(bodies[0]).toMatchObject({ config: { provider: 'openai', vision: 'on' } });
expect(bodies[0]).not.toHaveProperty('config.multimodal');
});
it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => {
server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([]));
render(<AddonManager />);
expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
)),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('manifest not found');
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
render(<AddonManager />);
await screen.findByText('Provider');
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
await user.tab();
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
expect(urls).toHaveLength(0);
});
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
@@ -901,6 +667,10 @@ describe('AddonManager', () => {
</>
);
// Folded like Collab's and Documents' options until the bar is opened.
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
await unfoldShelves();
const toggle = await screen.findByRole('button', { name: 'wanderer' });
expect(isOn(toggle)).toBe(false);
await userEvent.click(toggle);
@@ -921,6 +691,7 @@ describe('AddonManager', () => {
</>
);
await unfoldShelves();
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
+34 -174
View File
@@ -1,9 +1,7 @@
import { useId, type ComponentType } from 'react'
import type { ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
@@ -12,11 +10,10 @@ import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel'
import { useAddonManager } from './addons/useAddonManager'
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
import AiAddonTile from './addons/AiAddonTile'
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
import WandererMark from '../Tours/wanderer/WandererMark'
@@ -74,7 +71,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const { t } = useTranslation()
const dark = useIsDark()
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
mapSwitches,
@@ -154,33 +151,29 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
})
}
// An array, not a fragment: the tile counts its rows to fold them behind the
// "N options" bar the way Collab's and Documents' are.
if (addon.id === 'tours' && addon.enabled) {
return (
<>
return [
<AddonSubRow
key="wanderer"
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>,
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
key={key}
icon={<Icon size={14} />}
title={t(titleKey)}
description={t(subtitleKey)}
enabled={enabled}
onToggle={() => void toggle()}
/>
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
key={key}
icon={<Icon size={14} />}
title={t(titleKey)}
description={t(subtitleKey)}
enabled={enabled}
onToggle={() => void toggle()}
/>
))}
</>
)
}
// The AI-parsing settings hang off their tile like every other sub-feature,
// just as a form instead of toggle rows.
if (addon.id === 'llm_parsing' && addon.enabled) {
return <LlmParsingConfig addon={addon} />
)),
]
}
return null
}
@@ -209,9 +202,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{ key: 'trip', addons: tripAddons },
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter(g => g.addons.length > 0)
].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0))
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + aiAddons.length
const subtitle = (
<span className="inline-flex flex-wrap items-center gap-1">
@@ -242,8 +235,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const meta = TYPE_META[g.key]
return (
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
<div className="divide-y divide-edge-faint">
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{g.addons.map(tile)}
</div>
</section>
)
})}
@@ -282,140 +279,3 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
</div>
)
}
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
* overrides per-user config (see server llmConfig.ts). The API key is masked on
* read; an unchanged mask is treated as a no-op by the server. For the local
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const { t } = useTranslation()
const {
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
} = useLlmParsingConfig(addon)
const fieldId = useId()
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
/* Lives in the tile's shelf like the collab toggles, so it is one column of
eyebrow fields: the band this used to be had a whole page width. */
return (
<li className={`flex flex-col gap-3 p-3 ${saving ? 'opacity-60' : ''}`}>
<SettingsHint>
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
</SettingsHint>
<EditorField label="Provider">
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</EditorField>
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
</EditorField>
<EditorField
label="API key"
htmlFor={`${fieldId}-key`}
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
>
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</EditorField>
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</EditorField>
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
<Segmented<LlmVision>
label={t('settings.aiParsing.multimodal')}
value={vision}
onChange={v => setVision(asLlmVision(v))}
options={visionOptions}
fill
/>
</EditorField>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
<div className="flex items-center gap-2">
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
<button type="button" onClick={loadModels} disabled={loadingModels}
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
style={fs(11.5, 'body')}>
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button type="button"
key={name}
title={name}
onClick={() => setModel(name)}
aria-pressed={model === name}
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
style={fs(11.5)}
>
{name}
</button>
))}
</div>
)}
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className="min-w-0" title={m.note}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
{installedHere ? (
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
{isPulling && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
)
})}
</div>
</div>
)}
<div className="flex justify-end">
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</li>
)
}
@@ -0,0 +1,95 @@
import { ChevronRight, Settings2, Sparkles } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../../i18n';
import { StatusPill } from '../../Settings/settingsKit';
import { fs } from '../../shared/DialogShell';
import AddonSubRow from '../AddonSubRow';
import { type Addon, getAddonLabel } from './addonModel';
import { AI_ADDON_ICONS, type AiAddonId } from './aiAddons';
import AiSettingsDialog from './AiAdminConfig';
import { useAiAddonTile } from './useAiAddonTile';
/**
* The AI addons as one tile at the head of the integration column: the
* assistant, AI parsing and MCP as switches in its shelf, and one button to
* the dialog that holds their settings, so a page with all three on stays as
* short as one with none.
*/
export default function AiAddonTile({
addons,
onToggle,
}: {
addons: Addon[];
onToggle: (addon: Addon) => void;
}): React.ReactElement {
const { t } = useTranslation();
const tile = useAiAddonTile(addons);
const anyOn = tile.enabledCount > 0;
return (
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
<div className="flex items-start gap-3">
<div
className={`grid h-9 w-9 flex-none place-items-center rounded-[10px] transition-colors ${
anyOn
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
: 'bg-surface-tertiary text-content-faint'
}`}
>
<Sparkles size={18} />
</div>
<div className="min-w-0 flex-1 pt-px">
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
{t('admin.addons.ai.name')}
</h4>
<p
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${anyOn ? 'text-content-muted' : 'text-content-faint'}`}
style={fs(11.5)}
>
{t('admin.addons.ai.description')}
</p>
</div>
<div className="flex-none pt-1">
<StatusPill tone={anyOn ? 'success' : 'neutral'}>
{tile.enabledCount}/{addons.length}
</StatusPill>
</div>
</div>
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
{addons.map((addon) => {
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
const label = getAddonLabel(t, addon);
return (
<AddonSubRow
key={addon.id}
icon={<Icon size={14} />}
title={label.name}
description={label.description}
enabled={addon.enabled}
onToggle={() => onToggle(addon)}
/>
);
})}
</ul>
{tile.canSetUp && (
<button
type="button"
onClick={tile.openDialog}
className="flex min-h-[44px] w-full items-center gap-2.5 border-t border-edge-faint px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
>
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
<Settings2 size={14} />
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.addons.ai.setup')}
</span>
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
</button>
)}
</div>
{tile.dialogOpen && <AiSettingsDialog addons={addons} onClose={tile.closeDialog} />}
</article>
);
}
@@ -0,0 +1,339 @@
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-020: the AI settings dialog behind the AI tile.
import userEvent from '@testing-library/user-event';
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
import { delay, http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
import { useAssistantStore } from '../../../store/assistantStore';
import { ToastContainer } from '../../shared/Toast';
import type { Addon } from './addonModel';
const repo = vi.hoisted(() => ({
status: vi.fn(),
adminSettings: vi.fn(),
saveAdminSettings: vi.fn(),
adminConnection: vi.fn(),
saveAdminConnection: vi.fn(),
removeAdminConnection: vi.fn(),
adminModels: vi.fn(),
}));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
const LIVE: AiAdminConnectionResponse = {
connection: {
scope: 'instance',
kind: 'openai',
model: 'gpt-5.5',
hint: '…9f3c',
status: 'ok',
host: 'api.openai.com',
baseUrl: null,
},
prefill: null,
};
function addon(id: string, enabled: boolean, config?: Record<string, unknown>): Addon {
return { id, name: id, description: '', icon: 'Sparkles', type: 'integration', enabled, config } as Addon;
}
const ASSISTANT = addon('ai_assistant', true);
const parsing = (config: Record<string, unknown> = {}) => addon('llm_parsing', true, config);
function renderDialog(addons: Addon[] = [ASSISTANT]) {
const onClose = vi.fn();
render(
<>
<ToastContainer />
<AiSettingsDialog addons={addons} onClose={onClose} />
</>
);
return { onClose };
}
/** The dialog opened on AI parsing's tab. */
async function renderParsing(config: Record<string, unknown> = {}) {
const user = userEvent.setup();
renderDialog([ASSISTANT, parsing(config)]);
await user.click(await screen.findByRole('button', { name: 'AI Parsing' }));
return user;
}
function modelsRoute(names: string[], seen?: (string | null)[]) {
return http.get('/api/admin/llm/local/models', ({ request }) => {
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
});
}
function saveRoute(bodies: unknown[], fail = false) {
return http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return fail ? HttpResponse.error() : HttpResponse.json({ success: true });
});
}
beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
repo.status.mockRejectedValue(new Error('not needed'));
repo.adminSettings.mockResolvedValue(SETTINGS);
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
repo.adminConnection.mockResolvedValue({ connection: null, prefill: null });
repo.removeAdminConnection.mockResolvedValue(undefined);
repo.adminModels.mockResolvedValue([{ id: 'gpt-5.5', label: 'GPT-5.5', supportsTools: true }]);
});
describe('the connection and the assistant', () => {
it('FE-COMP-AI-ADMIN-001: shows who pays and the setup for everyone, the limits on the assistant tab', async () => {
const user = userEvent.setup();
renderDialog();
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
// The admin may share their own ChatGPT plan with everyone, so its tile is offered too.
expect(screen.getByRole('button', { name: /Sign in with ChatGPT/ })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
expect(screen.getByText('Keep chats')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
const user = userEvent.setup();
renderDialog();
await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' }));
await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument());
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => {
const user = userEvent.setup();
repo.adminConnection.mockResolvedValue(LIVE);
renderDialog();
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('…9f3c')).toBeInTheDocument();
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Change' }));
expect(screen.getByText('What would you like to chat with?')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.getByText('OpenAI')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-004: removing the connection asks first', async () => {
const user = userEvent.setup();
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue({ connection: null, prefill: null });
renderDialog();
await user.click(await screen.findByRole('button', { name: 'Remove connection' }));
expect(screen.getByText('Disconnect OpenAI? Your chats stay.')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Remove connection' });
await user.click(buttons[buttons.length - 1]);
expect(await screen.findByText('No connection yet.')).toBeInTheDocument();
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
});
it('FE-COMP-AI-ADMIN-005: a tab shows only while its addon is on, and Close closes', async () => {
const user = userEvent.setup();
const { onClose } = renderDialog([addon('ai_assistant', false), parsing()]);
expect(await screen.findByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'AI Assistant' })).not.toBeInTheDocument();
const closes = screen.getAllByRole('button', { name: 'Close' });
await user.click(closes[closes.length - 1]);
expect(onClose).toHaveBeenCalled();
});
});
describe('AI parsing', () => {
it('FE-COMP-AI-ADMIN-010: without its own connection parsing reads with the shared one, and can be given its own', async () => {
repo.adminConnection.mockResolvedValue(LIVE);
server.use(modelsRoute([]));
const user = await renderParsing({ vision: 'auto' });
expect(await screen.findByText('Shared')).toBeInTheDocument();
expect(screen.getByText('gpt-5.5')).toBeInTheDocument();
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Use a different one' }));
expect(screen.getByLabelText('Model')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-011: with no connection anywhere it says so and leads to the connection tab', async () => {
const user = await renderParsing();
expect(
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
).toBeInTheDocument();
// The link in the hint, not the tab of the same name above it.
const link = screen.getAllByRole('button', { name: 'Connection' }).find((b) => !b.hasAttribute('aria-pressed'));
await user.click(link as HTMLElement);
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-012: an own connection is shown as such and goes back to the shared one, keeping the vision setting', async () => {
const bodies: unknown[] = [];
repo.adminConnection.mockResolvedValue(LIVE);
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'anthropic', model: 'claude-x', apiKey: '••••••••', vision: 'on' });
expect(await screen.findByText('Own connection', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByText('claude-x')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Use the shared connection' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({ config: { vision: 'on' } });
expect(await screen.findByText('Shared')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-013: the local provider lists installed models and a chip fills the model field', async () => {
const urls: (string | null)[] = [];
server.use(modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
const user = await renderParsing({ provider: 'local', model: 'old' });
await screen.findByText('Installed on the server');
await user.click(await screen.findByRole('button', { name: 'llama3:8b' }));
expect(urls[0]).toBe('http://localhost:11434/v1');
expect(screen.getByLabelText('Model')).toHaveValue('llama3:8b');
// qwen3.5:4b is installed already, so the recommended row offers Use instead of Pull.
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
it("FE-COMP-AI-ADMIN-014: an unreachable Ollama shows the server's reason and Refresh retries", async () => {
let calls = 0;
server.use(
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
})
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('down');
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet. Pull one below.');
expect(calls).toBe(2);
});
it('FE-COMP-AI-ADMIN-015: switching providers swaps the address placeholder and the Ollama block, and drops a stale address', async () => {
const bodies: unknown[] = [];
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'local', model: 'm', baseUrl: 'http://ollama.lan:11434/v1' });
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toHaveValue('');
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: 'Anthropic' }));
// A gateway can stand in for Anthropic too, so the field stays (#2655).
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
await user.clear(screen.getByLabelText('Model'));
await user.type(screen.getByLabelText('Model'), ' claude-haiku ');
await user.type(screen.getByLabelText('API key'), 'sk-ant-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('AI settings saved');
// The stale local address must not ride along to Anthropic, and the model is trimmed.
expect(bodies[0]).toEqual({
config: { provider: 'anthropic', model: 'claude-haiku', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
});
});
it('FE-COMP-AI-ADMIN-016: a hand-typed address is asked on blur, a cloud provider asks nothing', async () => {
const urls: (string | null)[] = [];
server.use(modelsRoute([], urls));
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('Installed on the server');
await user.type(screen.getByLabelText('Base URL'), 'http://ollama.lan:11434/v1');
await user.tab();
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
const before = urls.length;
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
await user.type(screen.getByLabelText('Base URL'), 'https://proxy.local/v1');
await user.tab();
expect(urls).toHaveLength(before);
});
it('FE-COMP-AI-ADMIN-017: a pull streams progress and then selects the model', async () => {
let pulled: unknown = null;
let modelCalls = 0;
server.use(
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
await delay(150);
return new HttpResponse(
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } }
);
})
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('No models installed yet. Pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
expect(await screen.findAllByText('Pulling…')).not.toHaveLength(0);
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
it('FE-COMP-AI-ADMIN-018: an error frame or a refused pull is reported and the button comes back', async () => {
server.use(
modelsRoute([]),
http.post(
'/api/admin/llm/local/pull',
() =>
new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', {
headers: { 'Content-Type': 'application/x-ndjson' },
})
)
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('No models installed yet. Pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('manifest not found');
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
});
it('FE-COMP-AI-ADMIN-019: saving keeps a masked key and reports both outcomes', async () => {
const bodies: unknown[] = [];
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••' });
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
});
server.use(saveRoute(bodies, true));
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Could not save AI settings');
});
it('FE-COMP-AI-ADMIN-020: whether the model reads images saves at once, with the connection as saved', async () => {
const bodies: unknown[] = [];
server.use(saveRoute(bodies));
const user = await renderParsing({
provider: 'openai',
model: 'gpt-4.1-mini',
baseUrl: '',
apiKey: 'sk',
vision: 'off',
});
expect(await screen.findByRole('button', { name: 'No' })).toBeInTheDocument();
expect(
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Yes' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({
config: { provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'on' },
});
});
});
@@ -0,0 +1,270 @@
import { Check, Loader2, Sparkles } from 'lucide-react';
import React, { useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import ConfirmDialog from '../../shared/ConfirmDialog';
import CustomSelect from '../../shared/CustomSelect';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
import { NumericInput } from '../../shared/NumericInput';
import { EditorField, INPUT, Segmented } from '../../shared/dialogParts';
import ParsingSettings from './ParsingSettings';
import type { Addon } from './addonModel';
import { POLICY_CHOICES, useAiAdminConfig } from './useAiAdminConfig';
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint';
/** Who pays, as three rows to pick one from: the labels are too long for a segmented track in a shelf. */
function PolicyChoice({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<span className={EYEBROW} style={fs(9.5)}>
{t('assistant.admin.policy')}
</span>
<div role="radiogroup" aria-label={t('assistant.admin.policy')} className="flex flex-col gap-1.5">
{POLICY_CHOICES.map((choice) => {
const on = A.policy === choice.value;
return (
<button
key={choice.value}
type="button"
role="radio"
aria-checked={on}
onClick={() => A.setPolicy(choice.value)}
className={`flex items-center gap-2 rounded-[10px] border px-3 py-2 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
>
<span
className={`min-w-0 flex-1 font-medium ${on ? 'text-content' : 'text-content-muted'}`}
style={fs(12.5, 'body')}
>
{t(choice.labelKey)}
</span>
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-content" />}
</button>
);
})}
</div>
{A.policyHintKey && <SettingsHint>{t(A.policyHintKey)}</SettingsHint>}
</div>
);
}
/** The connection everyone runs on, once set up: who answers, its model, and replacing or removing it. */
function LiveConnection({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const { live, provider, models, busy, setModel, replace, askRemove } = A.instance;
if (!live) return null;
const status = CONNECTION_STATUS[live.status];
return (
<div
className={`flex flex-col gap-3 rounded-[12px] border border-edge-faint bg-surface-card p-3 ${busy ? 'opacity-60' : ''}`}
>
<div className="flex items-center gap-2.5">
<ProviderMark kind={live.kind} />
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
{provider}
</p>
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
{live.hint ?? live.host}
</p>
</div>
<StatusPill tone={status.tone}>{t(status.labelKey)}</StatusPill>
</div>
{live.status === 'ok' && (
<EditorField label={t('assistant.connect.model')} hint={t('assistant.connect.modelHint')}>
<CustomSelect
ariaLabel={t('assistant.connect.model')}
value={live.model}
onChange={(v) => setModel(String(v))}
options={models}
size="sm"
disabled={busy}
searchable={models.length > 8}
/>
</EditorField>
)}
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
<button type="button" onClick={replace} disabled={busy} className={SETTINGS_BUTTON}>
{t('common.change')}
</button>
<button type="button" onClick={askRemove} disabled={busy} className={SETTINGS_BUTTON_DANGER}>
{t('assistant.admin.connectionRemove')}
</button>
</div>
</div>
);
}
function InstanceConnection({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const { live, connection, replacing, cancelReplace, onConnected } = A.instance;
return (
<div className="flex flex-col gap-2.5">
<span className={EYEBROW} style={fs(9.5)}>
{t('assistant.admin.connection')}
</span>
{!replacing && <LiveConnection A={A} />}
{!live && <SettingsHint>{t('assistant.admin.connectionNone')}</SettingsHint>}
{A.showPanel && (
<AiConnectPanel scope="instance" onConnected={onConnected} prefill={connection?.prefill ?? null} />
)}
{replacing && (
<button
type="button"
onClick={cancelReplace}
className={`${SETTINGS_BUTTON} self-start`}
style={fs(12.5, 'body')}
>
{t('common.cancel')}
</button>
)}
</div>
);
}
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
function AssistantLimits({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const fieldId = useId();
return (
<div className="flex flex-col gap-4">
{A.instancePays && (
<EditorField
label={t('assistant.admin.dailyLimit')}
htmlFor={`${fieldId}-daily`}
hint={t('assistant.admin.dailyLimitHint')}
>
<NumericInput
id={`${fieldId}-daily`}
mode="integer"
className={`${INPUT} tabular-nums`}
value={A.dailyDraft}
onValueChange={A.setDailyDraft}
onBlur={A.commitDailyLimit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
</EditorField>
)}
<EditorField label={t('assistant.admin.retention')}>
<CustomSelect
ariaLabel={t('assistant.admin.retention')}
value={A.retention}
onChange={(v) => A.setRetention(String(v))}
options={A.retentionOptions}
size="sm"
/>
</EditorField>
</div>
);
}
type AiTab = 'connection' | 'assistant' | 'parsing';
/**
* The AI settings behind the AI tile's button, in one dialog so the addon page
* stays short: the connection the assistant and AI parsing share (and who pays
* for it), the assistant's limits, and how bookings are read. A tab shows only
* while its addon is on. Each change saves at once.
*/
export default function AiSettingsDialog({
addons,
onClose,
}: {
addons: Addon[];
onClose: () => void;
}): React.ReactElement {
const { t } = useTranslation();
const labelId = useId();
const A = useAiAdminConfig();
const assistantOn = addons.some((a) => a.id === 'ai_assistant' && a.enabled);
const parsing = addons.find((a) => a.id === 'llm_parsing' && a.enabled) ?? null;
const tabs: { value: AiTab; label: string }[] = [
{ value: 'connection', label: t('admin.addons.ai.tab.connection') },
...(assistantOn ? [{ value: 'assistant' as const, label: t('admin.addons.catalog.ai_assistant.name') }] : []),
...(parsing ? [{ value: 'parsing' as const, label: t('admin.addons.catalog.llm_parsing.name') }] : []),
];
const [tab, setTab] = useState<AiTab>('connection');
const shown = tabs.some((x) => x.value === tab) ? tab : 'connection';
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="detail"
align="top"
blocked={A.instance.confirmingRemove}
header={
<DialogHeader
tile={
<DialogTile>
<Sparkles size={20} strokeWidth={2} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
eyebrow={t('admin.addons.ai.name')}
title={t('admin.addons.ai.setup')}
sub={t('admin.addons.ai.dialogSub')}
subWraps
onClose={onClose}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={onClose}>
{t('common.close')}
</DialogButton>
</DialogFooter>
}
>
{tabs.length > 1 && (
<Segmented<AiTab> label={t('admin.addons.ai.setup')} value={shown} onChange={setTab} options={tabs} fill />
)}
{!A.loaded ? (
<div className="grid place-items-center py-10">
<Loader2 size={20} className="animate-spin text-content-faint" />
</div>
) : (
<>
{shown === 'connection' && (
<div className="flex flex-col gap-4">
<PolicyChoice A={A} />
{A.instancePays && <InstanceConnection A={A} />}
</div>
)}
{shown === 'assistant' && <AssistantLimits A={A} />}
{shown === 'parsing' && parsing && (
<ParsingSettings addon={parsing} A={A} onOpenConnection={() => setTab('connection')} />
)}
</>
)}
<ConfirmDialog
isOpen={A.instance.confirmingRemove}
onClose={A.instance.cancelRemove}
onConfirm={A.instance.remove}
title={t('assistant.admin.connectionRemove')}
message={t('assistant.connect.disconnectConfirm', { provider: A.instance.provider })}
confirmLabel={t('assistant.admin.connectionRemove')}
danger
/>
</DialogShell>
);
}
@@ -0,0 +1,348 @@
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared';
import { Check, RefreshCw } from 'lucide-react';
import React, { useId } from 'react';
import { useTranslation } from '../../../i18n';
import { ProviderMark } from '../../Settings/ai/AiConnectPanel';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import { DialogSection, fs } from '../../shared/DialogShell';
import { EditorField, INPUT, PANEL, Segmented } from '../../shared/dialogParts';
import { type Addon, ANTHROPIC_HINT, MASKED, RECOMMENDED_MODELS } from './addonModel';
import type { useAiAdminConfig } from './useAiAdminConfig';
import { useLlmParsingConfig } from './useLlmParsingConfig';
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
type Parsing = ReturnType<typeof useLlmParsingConfig>;
type MarkKind = Parameters<typeof ProviderMark>[0]['kind'];
/** Parsing's own providers, with the mark the assistant shows for each. */
const OWN_PROVIDERS = [
{ value: 'local', labelKey: 'settings.aiParsing.providerLocal', mark: 'custom' },
{ value: 'openai', labelKey: 'settings.aiParsing.providerOpenai', mark: 'openai' },
{ value: 'anthropic', labelKey: 'settings.aiParsing.providerAnthropic', mark: 'anthropic' },
] as const;
const markOf = (provider: string): MarkKind => OWN_PROVIDERS.find((p) => p.value === provider)?.mark ?? 'custom';
/** The connection parsing reads with, as one line: a mark, who answers, the model and where it comes from. */
function ReadsWith({ mark, title, model, tag }: { mark: MarkKind; title: string; model: string; tag: string }) {
return (
<div className="flex items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
<ProviderMark kind={mark} />
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
{title}
</p>
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
{model}
</p>
</div>
<StatusPill>{tag}</StatusPill>
</div>
);
}
/** Which connection parsing reads with now, and the way to the other one. */
function ParsingSource({ A, P, onOpenConnection }: { A: AiAdmin; P: Parsing; onOpenConnection: () => void }) {
const { t } = useTranslation();
const live = A.instancePays ? A.instance.live : null;
if (P.ownConnection) {
const own = OWN_PROVIDERS.find((p) => p.value === P.savedProvider);
return (
<div className="flex flex-col gap-2">
<ReadsWith
mark={markOf(P.savedProvider)}
title={own ? t(own.labelKey) : P.savedProvider}
model={P.savedModel}
tag={t('admin.addons.ai.parsing.own')}
/>
<SettingsHint>{t('admin.addons.ai.parsing.ownHint')}</SettingsHint>
<button
type="button"
onClick={() => void P.switchToShared()}
disabled={P.saving}
className={`${SETTINGS_BUTTON} self-start`}
style={fs(12.5, 'body')}
>
{t('admin.addons.ai.parsing.useShared')}
</button>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{live ? (
<ReadsWith
mark={live.kind}
title={A.instance.provider}
model={live.model}
tag={t('admin.addons.ai.parsing.sharedTag')}
/>
) : (
<SettingsHint>
{t(A.policy === 'user' ? 'admin.addons.ai.parsing.perUser' : 'admin.addons.ai.parsing.none')}
</SettingsHint>
)}
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
{!live && A.policy !== 'user' && (
<button type="button" onClick={onOpenConnection} className={SETTINGS_BUTTON_PRIMARY}>
{t('admin.addons.ai.tab.connection')}
</button>
)}
{!P.formOpen && (
<button type="button" onClick={P.startOwn} className={SETTINGS_BUTTON}>
{t('admin.addons.ai.parsing.setupOwn')}
</button>
)}
</div>
</div>
);
}
/** The models on the local server to pick from, and the recommended ones to pull. */
function LocalModels({ P }: { P: Parsing }) {
const { t } = useTranslation();
return (
<div className={PANEL}>
<div className="flex items-center gap-2">
<span
className="min-w-0 flex-1 truncate font-geist font-bold uppercase tracking-[.08em] text-content-faint"
style={fs(9.5)}
>
{t('admin.addons.ai.parsing.installed')}
</span>
<button
type="button"
onClick={() => void P.loadModels()}
disabled={P.loadingModels}
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-card hover:text-content disabled:cursor-default disabled:opacity-60"
style={fs(11.5, 'body')}
>
<RefreshCw size={11} strokeWidth={2.2} className={P.loadingModels ? 'animate-spin' : undefined} />
{t('admin.addons.ai.parsing.refresh')}
</button>
</div>
{P.modelsErr && (
<p className="m-0 break-words text-danger" style={fs(11.5)}>
{P.modelsErr}
</p>
)}
{!P.modelsErr && P.installed.length === 0 && !P.loadingModels && (
<SettingsHint>{t('admin.addons.ai.parsing.noModels')}</SettingsHint>
)}
{P.installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{P.installed.map((name) => (
<button
type="button"
key={name}
title={name}
onClick={() => P.setModel(name)}
aria-pressed={P.model === name}
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${P.model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
style={fs(11.5)}
>
{name}
</button>
))}
</div>
)}
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
{t('admin.addons.ai.parsing.pullTitle')}
</span>
{RECOMMENDED_MODELS.map((m) => {
const here = P.isInstalled(m.id);
const chosen = P.model === m.id;
return (
<div key={m.id} className="min-w-0" title={m.note}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{m.label}
</span>
{m.recommended && <StatusPill tone="success">{t('assistant.model.recommended')}</StatusPill>}
{here ? (
<button
type="button"
onClick={() => P.setModel(m.id)}
disabled={chosen}
className={SETTINGS_BUTTON}
style={fs(12, 'body')}
>
{chosen ? <Check size={13} strokeWidth={2.4} /> : null}
{t(chosen ? 'admin.addons.ai.parsing.selected' : 'admin.addons.ai.parsing.use')}
</button>
) : (
<button
type="button"
onClick={() => void P.pull(m.id)}
disabled={!!P.pulling}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(12, 'body')}
>
{t(P.pulling === m.id ? 'admin.addons.ai.parsing.pulling' : 'admin.addons.ai.parsing.pull')}
</button>
)}
</div>
{P.pulling === m.id && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div
className="h-full rounded-full bg-accent transition-[width] duration-200"
style={{ width: `${P.pullPct}%` }}
/>
</div>
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>
{P.pullStatus}
{P.pullPct ? ` ${P.pullPct}%` : ''}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/** A connection of parsing's own: the provider as tiles, its address, key and model. */
function OwnConnectionForm({ P }: { P: Parsing }) {
const { t } = useTranslation();
const fieldId = useId();
return (
<div className={`flex flex-col gap-3 ${P.saving ? 'opacity-60' : ''}`}>
<div
role="radiogroup"
aria-label={t('settings.aiParsing.provider')}
className="grid grid-cols-3 gap-2 max-sm:grid-cols-1"
>
{OWN_PROVIDERS.map((p) => {
const on = P.provider === p.value;
return (
<button
key={p.value}
type="button"
role="radio"
aria-checked={on}
onClick={() => P.setProvider(p.value)}
className={`flex min-w-0 items-center gap-2 rounded-[12px] border p-2.5 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
>
<ProviderMark kind={p.mark} size="sm" />
<span
className={`min-w-0 flex-1 truncate font-medium ${on ? 'text-content' : 'text-content-muted'}`}
style={fs(12.5, 'body')}
>
{t(p.labelKey)}
</span>
</button>
);
})}
</div>
<EditorField
label={t('settings.aiParsing.baseUrl')}
htmlFor={`${fieldId}-url`}
hint={t('settings.aiParsing.baseUrlHint')}
>
<input
id={`${fieldId}-url`}
type="url"
autoComplete="off"
className={INPUT}
value={P.baseUrl}
onChange={(e) => P.setBaseUrl(e.target.value)}
onBlur={() => void P.loadModels()}
placeholder={P.baseUrlPlaceholder}
/>
</EditorField>
<EditorField
label={t('settings.aiParsing.apiKey')}
htmlFor={`${fieldId}-key`}
hint={P.provider === 'anthropic' ? ANTHROPIC_HINT : t('settings.aiParsing.apiKeyHint')}
>
<input
id={`${fieldId}-key`}
type="password"
autoComplete="off"
className={INPUT}
value={P.apiKey}
onChange={(e) => P.setApiKey(e.target.value)}
placeholder={
P.apiKey === MASKED ? MASKED : P.provider === 'local' ? t('admin.addons.ai.parsing.apiKeyOptional') : ''
}
/>
</EditorField>
<EditorField label={t('settings.aiParsing.model')} htmlFor={`${fieldId}-model`}>
<input
id={`${fieldId}-model`}
autoComplete="off"
className={`${INPUT} font-geist`}
value={P.model}
onChange={(e) => P.setModel(e.target.value)}
/>
</EditorField>
{P.provider === 'local' && <LocalModels P={P} />}
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
{!P.ownConnection && (
<button type="button" onClick={P.cancelOwn} disabled={P.saving} className={SETTINGS_BUTTON}>
{t('common.cancel')}
</button>
)}
<button
type="button"
onClick={() => void P.save()}
disabled={P.saving || !P.model.trim()}
className={SETTINGS_BUTTON_PRIMARY}
>
{t(P.saving ? 'common.saving' : 'common.save')}
</button>
</div>
</div>
);
}
/**
* AI parsing in the AI dialog: which connection reads the bookings (the shared
* one, or one of its own an older install kept), a connection of its own when
* wanted, and whether the model reads images. Each part saves on its own.
*/
export default function ParsingSettings({
addon,
A,
onOpenConnection,
}: {
addon: Addon;
A: AiAdmin;
onOpenConnection: () => void;
}): React.ReactElement {
const { t } = useTranslation();
const P = useLlmParsingConfig(addon);
const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) }));
const local =
(P.ownConnection ? P.savedProvider : A.instance.live?.kind === 'custom' ? 'local' : 'cloud') === 'local';
return (
<div className="flex flex-col gap-5">
<DialogSection label={t('admin.addons.ai.parsing.readsWith')}>
<ParsingSource A={A} P={P} onOpenConnection={onOpenConnection} />
</DialogSection>
{P.formOpen && (
<DialogSection label={t('admin.addons.ai.parsing.own')}>
<OwnConnectionForm P={P} />
</DialogSection>
)}
<DialogSection label={t('settings.aiParsing.multimodal')}>
<Segmented<LlmVision>
label={t('settings.aiParsing.multimodal')}
value={P.vision}
onChange={(v) => P.setVision(asLlmVision(v))}
options={visionOptions}
fill
/>
<div className="mt-1.5">
<SettingsHint>
{t(local ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
</SettingsHint>
</div>
</DialogSection>
</div>
);
}
@@ -0,0 +1,32 @@
import { Cable, type LucideIcon, MessagesSquare, ScanText } from 'lucide-react';
import type { Addon } from './addonModel';
/**
* The three addons that talk to a language model, shown as one AI tile in the
* integration column: the assistant in the trip, AI parsing (booking import)
* and MCP (TREK inside other AI apps). Their ids, rows and settings stay their
* own; only the page groups them. Assistant and parsing share one connection.
*/
const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const;
export type AiAddonId = (typeof AI_ADDON_IDS)[number];
/** Each one's glyph, keyed by id: the icon column of an existing install still holds what it was seeded with. */
export const AI_ADDON_ICONS: Record<AiAddonId, LucideIcon> = {
ai_assistant: MessagesSquare,
llm_parsing: ScanText,
mcp: Cable,
};
export function isAiAddon(addon: Addon): boolean {
return (AI_ADDON_IDS as readonly string[]).includes(addon.id);
}
/** The AI addons this install has, in the tile's order. */
export function aiAddonsOf(addons: Addon[]): Addon[] {
return AI_ADDON_IDS.flatMap((id) => addons.find((a) => a.id === id) ?? []);
}
/** Whether the shared connection is used at all: the assistant or AI parsing is on. */
export function usesConnection(addons: Addon[]): boolean {
return addons.some((a) => (a.id === 'ai_assistant' || a.id === 'llm_parsing') && a.enabled);
}
@@ -26,6 +26,8 @@ const ADDONS = [
addon('llm_parsing', 'integration', false),
addon('immich', 'photo_provider', false),
addon('gdrive', 'document_provider'),
addon('airtrail', 'integration', false),
addon('mcp', 'integration'),
];
let loadAddons: ReturnType<typeof vi.fn<() => Promise<void>>>;
@@ -60,14 +62,16 @@ function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' |
}
describe('useAddonManager', () => {
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration, AI and the two shelves', async () => {
const { result, rerender } = await loaded();
rerender();
expect(adminApi.addons).toHaveBeenCalledTimes(1);
const ids = (list: { id?: string; key?: string }[]) => list.map((a) => a.id ?? a.key);
expect(ids(result.current.tripAddons)).toEqual(['todo', 'documents']);
expect(ids(result.current.globalAddons)).toEqual(['journey']);
expect(ids(result.current.integrationAddons)).toEqual(['llm_parsing']);
// The AI addons leave the integration list for their own tile, in its order.
expect(ids(result.current.integrationAddons)).toEqual(['airtrail']);
expect(ids(result.current.aiAddons)).toEqual(['llm_parsing', 'mcp']);
expect(result.current.providerOptions).toEqual([
expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }),
]);
@@ -9,6 +9,7 @@ import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useToast } from '../../shared/Toast';
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
import { aiAddonsOf, isAiAddon } from './aiAddons';
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
function loadToursFlag(
@@ -139,7 +140,9 @@ export function useAddonManager() {
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
const globalAddons = addons.filter((a) => a.type === 'global');
const integrationAddons = addons.filter((a) => a.type === 'integration');
// The AI addons leave the list to sit together on one tile at its head.
const integrationAddons = addons.filter((a) => a.type === 'integration' && !isAiAddon(a));
const aiAddons = aiAddonsOf(addons);
const providerOptions = addons.filter((a) => a.type === 'photo_provider').map(asOption);
// No credential form under these, unlike the photo providers: a document connection
// belongs to a trip and is entered there. The admin only decides whether a provider
@@ -153,6 +156,7 @@ export function useAddonManager() {
tripAddons,
globalAddons,
integrationAddons,
aiAddons,
providerOptions,
documentProviderOptions,
toursWanderer,
@@ -0,0 +1,23 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router';
import type { Addon } from './addonModel';
import { usesConnection } from './aiAddons';
/**
* The AI tile behind both admin shells: its three switches, how many are on,
* and the settings dialog. The dialog opens by itself when OpenRouter's
* sign-in returns here (`?ai=openrouter`), since the connection form that
* finishes it lives inside.
*/
export function useAiAddonTile(aiAddons: Addon[]) {
const [params] = useSearchParams();
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
const canSetUp = usesConnection(aiAddons);
return {
enabledCount: aiAddons.filter((a) => a.enabled).length,
canSetUp,
dialogOpen: open && canSetUp,
openDialog: () => setOpen(true),
closeDialog: () => setOpen(false),
};
}
@@ -0,0 +1,167 @@
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
import { MASKED_SETTING_VALUE } from '@trek/shared';
import { createElement, type ReactNode } from 'react';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { useAssistantStore } from '../../../store/assistantStore';
const repo = vi.hoisted(() => ({
status: vi.fn(),
adminSettings: vi.fn(),
saveAdminSettings: vi.fn(),
adminConnection: vi.fn(),
saveAdminConnection: vi.fn(),
removeAdminConnection: vi.fn(),
adminModels: vi.fn(),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const { useAiAdminConfig } = await import('./useAiAdminConfig');
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(
MemoryRouter,
{ initialEntries: ['/admin?tab=addons'] },
createElement(TranslationProvider, null, children)
);
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
const LIVE: AiAdminConnectionResponse = {
connection: {
scope: 'instance',
kind: 'anthropic',
model: 'claude-sonnet',
hint: '…a1b2',
status: 'ok',
host: 'api.anthropic.com',
baseUrl: null,
},
prefill: null,
};
async function renderLoaded() {
const view = renderHook(() => useAiAdminConfig(), { wrapper });
await waitFor(() => expect(view.result.current.loaded).toBe(true));
return view;
}
beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
repo.status.mockRejectedValue(new Error('not needed'));
repo.adminSettings.mockResolvedValue(SETTINGS);
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
repo.adminConnection.mockResolvedValue(NONE);
repo.saveAdminConnection.mockResolvedValue(LIVE);
repo.removeAdminConnection.mockResolvedValue(undefined);
repo.adminModels.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]);
});
describe('useAiAdminConfig', () => {
it('FE-HOOK-AI-ADMIN-001: loads the settings and, without a connection, opens the setup for everyone', async () => {
const { result } = await renderLoaded();
expect(result.current.policy).toBe('instance_or_user');
expect(result.current.policyHintKey).toBe('assistant.admin.policy.instanceOrUserHint');
expect(result.current.instancePays).toBe(true);
expect(result.current.showPanel).toBe(true);
expect(result.current.dailyDraft).toBe('50');
expect(result.current.retention).toBe('90');
expect(result.current.retentionOptions.map((o) => o.label)).toEqual(['30 days', '90 days', '365 days', 'Forever']);
expect(repo.status).toHaveBeenCalled();
});
it('FE-HOOK-AI-ADMIN-002: a policy change saves at once, and a refused one is taken back', async () => {
const { result } = await renderLoaded();
await act(async () => result.current.setPolicy('user'));
await waitFor(() => expect(result.current.policy).toBe('user'));
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
expect(result.current.instancePays).toBe(false);
repo.saveAdminSettings.mockRejectedValueOnce(new Error('nope'));
await act(async () => result.current.setPolicy('instance'));
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
expect(result.current.policy).toBe('user');
});
it('FE-HOOK-AI-ADMIN-003: the daily limit is kept within 1 to 10000 and saved when the field is left', async () => {
const { result } = await renderLoaded();
act(() => result.current.setDailyDraft('20000'));
await act(async () => result.current.commitDailyLimit());
expect(result.current.dailyDraft).toBe('10000');
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, dailyTurns: 10000 });
vi.clearAllMocks();
act(() => result.current.setDailyDraft(''));
await act(async () => result.current.commitDailyLimit());
expect(result.current.dailyDraft).toBe('10000');
expect(repo.saveAdminSettings).not.toHaveBeenCalled();
});
it('FE-HOOK-AI-ADMIN-004: a retention period set elsewhere stays selectable', async () => {
repo.adminSettings.mockResolvedValue({ ...SETTINGS, retention: { mode: 'days', days: 120 } });
const { result } = await renderLoaded();
expect(result.current.retentionOptions.map((o) => o.value)).toEqual(['30', '90', '120', '365', 'forever']);
await act(async () => result.current.setRetention('forever'));
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, retention: { mode: 'forever' } });
});
it('FE-HOOK-AI-ADMIN-005: another model keeps the stored key by sending the mask', async () => {
repo.adminConnection.mockResolvedValue(LIVE);
const { result } = await renderLoaded();
expect(result.current.showPanel).toBe(false);
await waitFor(() => expect(result.current.instance.models).toHaveLength(1));
await act(async () => result.current.instance.setModel('claude-haiku'));
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
kind: 'anthropic',
apiKey: MASKED_SETTING_VALUE,
model: 'claude-haiku',
});
});
it('FE-HOOK-AI-ADMIN-006: an own server without a key sends none', async () => {
repo.adminConnection.mockResolvedValue({
connection: {
scope: 'instance',
kind: 'custom',
model: 'llama3',
hint: null,
status: 'ok',
host: 'ollama:11434',
baseUrl: 'http://ollama:11434/v1',
},
prefill: null,
});
const { result } = await renderLoaded();
await act(async () => result.current.instance.setModel('qwen3:8b'));
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
kind: 'custom',
baseUrl: 'http://ollama:11434/v1',
model: 'qwen3:8b',
});
});
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
const { result } = await renderLoaded();
act(() => result.current.instance.replace());
expect(result.current.showPanel).toBe(true);
act(() => result.current.instance.cancelReplace());
expect(result.current.showPanel).toBe(false);
act(() => result.current.instance.askRemove());
expect(result.current.instance.confirmingRemove).toBe(true);
act(() => result.current.instance.remove());
await waitFor(() => expect(result.current.instance.live).toBeNull());
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
expect(result.current.instance.confirmingRemove).toBe(false);
expect(result.current.showPanel).toBe(true);
});
});
@@ -0,0 +1,221 @@
import {
MASKED_SETTING_VALUE,
type AiAdminConnectionResponse,
type AiAdminSettings,
type AiInstanceConnectionRequest,
type AiPolicy,
type AiRetention,
} from '@trek/shared';
import { useEffect, useEffectEvent, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
import { useAssistantStore } from '../../../store/assistantStore';
import { providerLabel } from '../../Assistant/providerCatalog';
import { connectErrorKey } from '../../Settings/ai/aiConnectModel';
import { useOpenrouterReturn } from '../../Settings/ai/useAiConnect';
import { modelOptions, useAiModels } from '../../Settings/ai/useAiConnections';
import { useToast } from '../../shared/Toast';
/** Who pays, as the admin reads it ("I pay" rather than "the instance"); the first is the default. */
export const POLICY_CHOICES: readonly { value: AiPolicy; labelKey: string; hintKey: string }[] = [
{
value: 'instance_or_user',
labelKey: 'assistant.admin.policy.instanceOrUser',
hintKey: 'assistant.admin.policy.instanceOrUserHint',
},
{ value: 'instance', labelKey: 'assistant.admin.policy.instance', hintKey: 'assistant.admin.policy.instanceHint' },
{ value: 'user', labelKey: 'assistant.admin.policy.user', hintKey: 'assistant.admin.policy.userHint' },
];
const RETENTION_DAYS = [30, 90, 365];
const FOREVER = 'forever';
const MAX_DAILY_TURNS = 10_000;
/** A retention as the select's value: its days, or `forever`. */
function retentionValue(retention: AiRetention): string {
return retention.mode === 'forever' ? FOREVER : String(retention.days);
}
function retentionOf(value: string): AiRetention {
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
}
/** Policy, daily limit and retention: each change is saved at once and taken back when the server refuses it. */
function useAdminSettings(onSaved: () => void) {
const { t } = useTranslation();
const toast = useToast();
const [settings, setSettings] = useState<AiAdminSettings | null>(null);
const [dailyDraft, setDailyDraft] = useState('');
const adopt = (next: AiAdminSettings) => {
setSettings(next);
setDailyDraft(String(next.dailyTurns));
};
const save = async (patch: Partial<AiAdminSettings>) => {
if (!settings) return;
const before = settings;
setSettings({ ...settings, ...patch });
try {
adopt(await aiConnectionsRepo.saveAdminSettings({ ...settings, ...patch }));
onSaved();
} catch {
adopt(before);
toast.error(t('assistant.error.generic'));
}
};
/** The typed limit, kept within what the server takes, saved when the field is left. */
const commitDailyLimit = () => {
if (!settings) return;
const typed = Number.parseInt(dailyDraft, 10);
const next = Number.isFinite(typed) ? Math.min(MAX_DAILY_TURNS, Math.max(1, typed)) : settings.dailyTurns;
setDailyDraft(String(next));
if (next !== settings.dailyTurns) void save({ dailyTurns: next });
};
const current = settings ? retentionValue(settings.retention) : '';
// A period set some other way stays selectable instead of reading as blank.
const unlisted = current !== '' && current !== FOREVER && !RETENTION_DAYS.includes(Number(current));
const days = unlisted ? [...RETENTION_DAYS, Number(current)].sort((a, b) => a - b) : RETENTION_DAYS;
return {
settings,
adopt,
setPolicy: (policy: AiPolicy) => void save({ policy }),
dailyDraft,
setDailyDraft,
commitDailyLimit,
retention: current,
retentionOptions: [
...days.map((d) => ({ value: String(d), label: t('assistant.admin.retentionDays', { count: d }) })),
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
],
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
};
}
/** The connection for everyone: shown, its model switched, replaced or removed. */
function useInstanceConnection(onChanged: () => void) {
const { t } = useTranslation();
const toast = useToast();
const [connection, setConnection] = useState<AiAdminConnectionResponse | null>(null);
const [replacing, setReplacing] = useState(false);
const [confirmingRemove, setConfirmingRemove] = useState(false);
const [busy, setBusy] = useState(false);
const live = connection?.connection ?? null;
const provider = live ? providerLabel(live.kind, live.host) : '';
const models = useAiModels(
live?.status === 'ok' ? () => aiConnectionsRepo.adminModels() : null,
live ? `${live.kind}@${live.host}` : ''
);
const act = async (action: () => Promise<AiAdminConnectionResponse>, doneKey: string | null) => {
setBusy(true);
try {
setConnection(await action());
onChanged();
if (doneKey) toast.success(t(doneKey));
} catch (err) {
toast.error(t(connectErrorKey(err), { provider }));
} finally {
setBusy(false);
}
};
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
const setModel = (model: string) => {
if (!live || live.kind === 'chatgpt') return;
const request: AiInstanceConnectionRequest =
live.kind === 'custom'
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
: { kind: live.kind, apiKey: MASKED_SETTING_VALUE, model };
void act(() => aiConnectionsRepo.saveAdminConnection(request), 'common.saved');
};
const remove = () => {
setConfirmingRemove(false);
void act(async () => {
await aiConnectionsRepo.removeAdminConnection();
return aiConnectionsRepo.adminConnection();
}, null);
};
const onConnected = () => {
setReplacing(false);
void act(() => aiConnectionsRepo.adminConnection(), null);
};
return {
connection,
setConnection,
live,
provider,
models: live
? modelOptions(models, live.model, {
free: t('assistant.model.free'),
recommended: t('assistant.model.recommended'),
})
: [],
busy,
setModel,
replacing,
replace: () => setReplacing(true),
cancelReplace: () => setReplacing(false),
onConnected,
confirmingRemove,
askRemove: () => setConfirmingRemove(true),
cancelRemove: () => setConfirmingRemove(false),
remove,
};
}
/**
* The AI assistant's admin settings behind both admin shells, on the addon's
* own tile: who pays, the connection everyone runs on, the daily limit while
* the instance pays, and how long chats are kept. The assistant's status is
* reloaded after every change, so the launcher appears or goes at once.
*/
export function useAiAdminConfig() {
const { t } = useTranslation();
const toast = useToast();
const loadStatus = useAssistantStore((s) => s.loadStatus);
const refreshStatus = () => void loadStatus();
const form = useAdminSettings(refreshStatus);
const instance = useInstanceConnection(refreshStatus);
useOpenrouterReturn(instance.onConnected);
const loadOnMount = useEffectEvent((isCancelled: () => boolean) => {
Promise.all([aiConnectionsRepo.adminSettings(), aiConnectionsRepo.adminConnection()]).then(
([settings, connection]) => {
if (isCancelled()) return;
form.adopt(settings);
instance.setConnection(connection);
},
() => {
if (!isCancelled()) toast.error(t('assistant.error.generic'));
}
);
// The connect panel reads whether OpenRouter can return here from the status.
refreshStatus();
});
useEffect(() => {
let cancelled = false;
loadOnMount(() => cancelled);
return () => {
cancelled = true;
};
}, []);
const policy = form.settings?.policy ?? null;
return {
...form,
instance,
loaded: form.settings !== null && instance.connection !== null,
policy,
policyHintKey: POLICY_CHOICES.find((c) => c.value === policy)?.hintKey ?? null,
/** Policy `user` charges nothing to the instance: no connection and no limit to set. */
instancePays: policy !== null && policy !== 'user',
showPanel: instance.live === null || instance.replacing,
};
}
@@ -5,10 +5,14 @@ import { adminApi } from '../../../api/client';
import { type Addon, DEFAULT_OLLAMA_URL, MASKED } from './addonModel';
import { useLlmParsingConfig } from './useLlmParsingConfig';
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-011
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-013
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
/** A local connection of parsing's own, so the form is open and lists the server's models. */
const OWN_LOCAL = { provider: 'local', model: 'm' };
function llm(config?: Record<string, unknown>): Addon {
return {
@@ -45,7 +49,7 @@ describe('useLlmParsingConfig', () => {
});
it('FE-HOOK-LLMCFG-002: the local provider lists the installed models from the default URL', async () => {
const { result } = renderHook(() => useLlmParsingConfig(llm()));
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL);
expect(result.current.isInstalled('qwen3.5:4b')).toBe(true);
@@ -54,7 +58,7 @@ describe('useLlmParsingConfig', () => {
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
const { result } = renderHook(() =>
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
useLlmParsingConfig(llm({ provider: 'openai', model: 'gpt', baseUrl: ' http://box:11434/v1 ' }))
);
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
await act(() => result.current.loadModels());
@@ -69,13 +73,13 @@ describe('useLlmParsingConfig', () => {
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(new Error('ECONNREFUSED'));
const { result } = renderHook(() => useLlmParsingConfig(llm()));
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
await waitFor(() => expect(result.current.modelsErr).toBe('ECONNREFUSED'));
expect(result.current.installed).toEqual([]);
expect(result.current.loadingModels).toBe(false);
vi.mocked(adminApi.llmLocalModels).mockRejectedValue('nope');
await act(() => result.current.loadModels());
expect(result.current.modelsErr).toBe('Could not reach the local LLM server');
expect(result.current.modelsErr).toBe('admin.addons.ai.parsing.unreachable');
});
it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => {
@@ -83,7 +87,7 @@ describe('useLlmParsingConfig', () => {
response: { status: 502, data: { error: 'Local LLM server rejected the API key (401)' } },
});
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(refused);
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)'));
});
@@ -94,14 +98,14 @@ describe('useLlmParsingConfig', () => {
progress = onProgress;
return new Promise<void>((r) => (finish = r));
});
const { result } = renderHook(() => useLlmParsingConfig(llm()));
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
let pending!: Promise<void>;
act(() => {
pending = result.current.pull('qwen3.5:4b');
});
expect(result.current.pulling).toBe('qwen3.5:4b');
expect(result.current.pullStatus).toBe('starting…');
expect(result.current.pullStatus).toBe('admin.addons.ai.parsing.pulling');
act(() => progress({ status: 'downloading', total: 200, completed: 50 }));
expect(result.current.pullStatus).toBe('downloading');
expect(result.current.pullPct).toBe(25);
@@ -109,7 +113,7 @@ describe('useLlmParsingConfig', () => {
finish();
await pending;
});
expect(toast.success).toHaveBeenCalledWith('Model pulled');
expect(toast.success).toHaveBeenCalledWith('admin.addons.ai.parsing.pulled');
expect(result.current.model).toBe('qwen3.5:4b');
expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2);
expect(result.current.pulling).toBeNull();
@@ -136,7 +140,7 @@ describe('useLlmParsingConfig', () => {
expect(toast.error).toHaveBeenCalledWith('manifest unknown');
vi.mocked(adminApi.llmLocalPull).mockRejectedValue('nope');
await act(() => result.current.pull('x'));
expect(toast.error).toHaveBeenLastCalledWith('Pull failed');
expect(toast.error).toHaveBeenLastCalledWith('admin.addons.ai.parsing.pullFailed');
expect(result.current.pulling).toBeNull();
});
@@ -148,18 +152,17 @@ describe('useLlmParsingConfig', () => {
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', {
config: { provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o/v1', apiKey: MASKED, vision: 'auto' },
});
expect(toast.success).toHaveBeenCalledWith('Saved');
expect(toast.success).toHaveBeenCalledWith('settings.aiParsing.toast.saved');
expect(result.current.saving).toBe(false);
});
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
act(() => {
result.current.setProvider('anthropic');
result.current.setVision('off');
});
await act(async () => result.current.setVision('off'));
act(() => result.current.setProvider('anthropic'));
await act(() => result.current.save());
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1]).toEqual({
const calls = vi.mocked(adminApi.updateAddon).mock.calls;
expect(calls[calls.length - 1]?.[1]).toEqual({
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
});
});
@@ -212,7 +215,35 @@ describe('useLlmParsingConfig', () => {
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
await act(() => result.current.save());
expect(toast.error).toHaveBeenCalledWith('Failed to save');
expect(toast.error).toHaveBeenCalledWith('settings.aiParsing.toast.saveError');
expect(result.current.saving).toBe(false);
});
it('FE-HOOK-LLMCFG-012: without its own connection the form stays shut and asks the server nothing until opened', async () => {
const { result } = renderHook(() => useLlmParsingConfig(llm({ vision: 'on' })));
expect(result.current.ownConnection).toBe(false);
expect(result.current.formOpen).toBe(false);
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
act(() => result.current.startOwn());
expect(result.current.formOpen).toBe(true);
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
// The vision setting alone is saved without a connection.
await act(async () => result.current.setVision('off'));
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('llm_parsing', { config: { vision: 'off' } });
act(() => result.current.cancelOwn());
expect(result.current.formOpen).toBe(false);
});
it('FE-HOOK-LLMCFG-013: going back to the shared connection drops the own one and keeps the vision setting', async () => {
const { result } = renderHook(() =>
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'claude-x', apiKey: MASKED, vision: 'on' }))
);
expect(result.current.ownConnection).toBe(true);
expect(result.current.savedModel).toBe('claude-x');
await act(() => result.current.switchToShared());
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', { config: { vision: 'on' } });
expect(result.current.ownConnection).toBe(false);
expect(result.current.formOpen).toBe(false);
expect(result.current.model).toBe('');
});
});
@@ -2,33 +2,118 @@ import { type LlmVision, asLlmVision } from '@trek/shared';
import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { useTranslation } from '../../../i18n';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
/**
* Instance-wide AI-parsing config behind both admin shells. When set, it applies to the
* whole instance and overrides per-user config (see server llmConfig.ts). The API key is
* masked on read; an unchanged mask is treated as a no-op by the server. For the local
* provider it also lists the installed Ollama models and can pull the recommended ones.
*/
export function useLlmParsingConfig(addon: Addon) {
const toast = useToast();
const cfg = addon.config ?? {};
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
const [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision));
const [saving, setSaving] = useState(false);
/** What AI parsing keeps in its addon config: a connection of its own (or none) and the vision setting. */
interface SavedParsing {
provider: string;
model: string;
baseUrl: string;
apiKey: string;
vision: LlmVision;
}
// Local-provider model management.
function savedOf(cfg: Record<string, unknown>): SavedParsing {
return {
provider: typeof cfg.provider === 'string' ? cfg.provider : '',
model: typeof cfg.model === 'string' ? cfg.model : '',
baseUrl: typeof cfg.baseUrl === 'string' ? cfg.baseUrl : '',
apiKey: typeof cfg.apiKey === 'string' ? cfg.apiKey : '',
vision: asLlmVision(cfg.vision),
};
}
/**
* The models a local (Ollama) server has, and pulling the recommended ones.
* The list loads while the form shows the local provider: a provider change or
* opening the form loads it, the address field's own blur reloads it.
*/
function useLocalModels(baseUrl: string, isLocal: boolean, formOpen: boolean, onPulled: (id: string) => void) {
const { t } = useTranslation();
const toast = useToast();
const [installed, setInstalled] = useState<string[]>([]);
const [modelsErr, setModelsErr] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const [pulling, setPulling] = useState<string | null>(null);
const [pullPct, setPullPct] = useState(0);
const [pullStatus, setPullStatus] = useState('');
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
const loadModels = async () => {
if (!isLocal) return;
setLoadingModels(true);
setModelsErr('');
try {
const res = await adminApi.llmLocalModels(effectiveUrl);
setInstalled(res.models.map((m) => m.name));
} catch (e: unknown) {
// The server says why (unreachable, key refused); axios' own status line does not.
setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable')));
setInstalled([]);
} finally {
setLoadingModels(false);
}
};
const loadModelsForProvider = useEffectEvent(() => {
if (formOpen && isLocal) void loadModels();
});
useEffect(() => {
loadModelsForProvider();
}, [isLocal, formOpen]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
setPullStatus(t('admin.addons.ai.parsing.pulling'));
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error);
if (p.status) setPullStatus(p.status);
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
});
toast.success(t('admin.addons.ai.parsing.pulled'));
onPulled(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
} finally {
setPulling(null);
setPullPct(0);
setPullStatus('');
}
};
return { installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull };
}
/**
* AI parsing's admin settings behind both admin shells. Parsing reads with the
* AI connection the assistant runs on unless the instance keeps one of its own
* here; an install set up before the two shared one keeps its own until the
* admin switches it over. The API key is masked on read and an unchanged mask
* keeps the stored key. For a local server it lists the installed Ollama models
* and pulls the recommended ones.
*/
export function useLlmParsingConfig(addon: Addon) {
const { t } = useTranslation();
const toast = useToast();
const [saved, setSaved] = useState<SavedParsing>(() => savedOf(addon.config ?? {}));
const [editing, setEditing] = useState(false);
const [provider, setProviderState] = useState<string>(saved.provider || 'local');
const [model, setModel] = useState<string>(saved.model);
const [baseUrl, setBaseUrl] = useState<string>(saved.baseUrl);
const [apiKey, setApiKey] = useState<string>(saved.apiKey);
const [saving, setSaving] = useState(false);
/** A connection of its own, which wins over the shared one. */
const ownConnection = !!(saved.provider && saved.model);
const formOpen = ownConnection || editing;
/**
* A different provider never inherits the last one's endpoint: an Ollama address
@@ -37,83 +122,58 @@ export function useLlmParsingConfig(addon: Addon) {
* keep an address the admin typed for it (#2655); empty means the provider default.
*/
const setProvider = (next: string) => {
if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : '');
if (next !== provider) setBaseUrl(next === saved.provider ? saved.baseUrl : '');
setProviderState(next);
};
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
const local = useLocalModels(baseUrl, provider === 'local', formOpen, setModel);
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
const loadModels = async () => {
if (provider !== 'local') return;
setLoadingModels(true);
setModelsErr('');
try {
const res = await adminApi.llmLocalModels(effectiveUrl);
setInstalled(res.models.map((m) => m.name));
} catch (e: unknown) {
// The server says why (unreachable, key refused); axios' own status line does not.
setModelsErr(getApiErrorMessage(e, 'Could not reach the local LLM server'));
setInstalled([]);
} finally {
setLoadingModels(false);
}
};
// Load installed models when the local provider is active. Only a provider change
// triggers it; the base URL is read as it stands then (its own blur reloads).
const loadModelsForProvider = useEffectEvent(() => {
if (provider === 'local') void loadModels();
});
useEffect(() => {
loadModelsForProvider();
}, [provider]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
setPullStatus('starting…');
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error);
if (p.status) setPullStatus(p.status);
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
});
toast.success('Model pulled');
setModel(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed');
} finally {
setPulling(null);
setPullPct(0);
setPullStatus('');
}
};
const save = async () => {
/** Writes the config; the stored key stays as long as the mask is sent in its place. */
const write = async (next: SavedParsing, config: Record<string, unknown>): Promise<boolean> => {
setSaving(true);
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, {
config: {
provider,
model: model.trim(),
baseUrl: baseUrl.trim(),
apiKey,
vision,
},
});
toast.success('Saved');
await adminApi.updateAddon(addon.id, { config });
setSaved(next);
toast.success(t('settings.aiParsing.toast.saved'));
return true;
} catch {
toast.error('Failed to save');
toast.error(t('settings.aiParsing.toast.saveError'));
return false;
} finally {
setSaving(false);
}
};
const ownConfig = (s: SavedParsing) => ({
provider: s.provider,
model: s.model,
baseUrl: s.baseUrl,
apiKey: s.apiKey,
});
const save = async () => {
const next: SavedParsing = { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, vision: saved.vision };
if (await write(next, { ...ownConfig(next), vision: next.vision })) setEditing(false);
};
/** Back on the shared connection: the own one and its key are dropped, the vision setting stays. */
const switchToShared = async () => {
const next: SavedParsing = { provider: '', model: '', baseUrl: '', apiKey: '', vision: saved.vision };
if (await write(next, { vision: next.vision })) {
setEditing(false);
setProviderState('local');
setModel('');
setBaseUrl('');
setApiKey('');
}
};
/** Saved at once, with the connection as it was saved: a draft in the form is not sent along. */
const setVision = (value: LlmVision) => {
const next = { ...saved, vision: value };
void write(next, ownConnection ? { ...ownConfig(saved), vision: value } : { vision: value });
};
return {
provider,
setProvider,
@@ -124,18 +184,17 @@ export function useLlmParsingConfig(addon: Addon) {
baseUrlPlaceholder,
apiKey,
setApiKey,
vision,
vision: saved.vision,
setVision,
saving,
installed,
modelsErr,
loadingModels,
pulling,
pullPct,
pullStatus,
isInstalled,
loadModels,
pull,
ownConnection,
savedProvider: saved.provider,
savedModel: saved.model,
formOpen,
startOwn: () => setEditing(true),
cancelOwn: () => setEditing(false),
switchToShared,
...local,
save,
};
}
@@ -0,0 +1,56 @@
/**
* FE-AI-ASK-001 to FE-AI-ASK-003
*
* "Ask" in the place inspector: there only while the assistant can run, only
* in the planner of the place's trip, and it opens the chat about the place.
*/
import type { AiStatusResponse } from '@trek/shared';
import { Route, Routes } from 'react-router';
import { afterEach, describe, expect, it } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import AssistantAskButton from './AssistantAskButton';
const INITIAL = useAssistantStore.getState();
const place = buildPlace({ id: 42, trip_id: 5, name: 'Kinkaku-ji' });
function renderAt(path: string) {
return render(
<Routes>
<Route path="/trips/:id" element={<AssistantAskButton place={place} />} />
<Route path="*" element={<AssistantAskButton place={place} />} />
</Routes>,
{ initialEntries: [path] }
);
}
afterEach(() => useAssistantStore.setState(INITIAL, true));
describe('AssistantAskButton', () => {
it('FE-AI-ASK-001: shows nothing until the assistant has a connection to use', () => {
renderAt('/trips/5');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('FE-AI-ASK-002: opens the assistant with the place as its context', () => {
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
renderAt('/trips/5');
fireEvent.click(screen.getByRole('button', { name: 'Ask: Kinkaku-ji' }));
const state = useAssistantStore.getState();
expect(state.window).toEqual({ open: true, minimized: false });
expect(chatOf(state, 5).focus).toEqual({ placeIds: [42] });
});
it('FE-AI-ASK-003: is not offered outside the planner of the trip the place belongs to', () => {
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
const { unmount } = renderAt('/trips/6');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
unmount();
renderAt('/collections/3');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,34 @@
import { Sparkles } from 'lucide-react';
import { useMatch } from 'react-router';
import { useTranslation } from '../../i18n';
import type { Place } from '../../types';
import Tooltip from '../shared/Tooltip';
import { useAssistantAsk } from './useAssistantAsk';
/**
* The place inspector's "Ask": a round button in its head, beside close, that
* opens the assistant about this place. Only in the planner of the place's own
* trip; a saved place in a collection has no trip to ask about.
*/
export default function AssistantAskButton({ place }: { place: Place }) {
const { t } = useTranslation();
const inTrip = useMatch('/trips/:id');
const tripId = inTrip && Number(inTrip.params.id) === place.trip_id ? place.trip_id : null;
const { available, askAbout } = useAssistantAsk(tripId);
if (!available) return null;
const label = t('assistant.context.ask');
return (
<Tooltip label={label}>
<button
type="button"
onClick={() => askAbout(place.id)}
aria-label={`${label}: ${place.name}`}
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content"
>
<Sparkles size={15} strokeWidth={2.2} aria-hidden="true" />
</button>
</Tooltip>
);
}
@@ -0,0 +1,351 @@
/**
* 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',
card: null,
});
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', card: null };
}
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 onOpen = vi.fn();
renderCard([receipt('c1', '2026-10-21T08:15:00.000Z')], { onOpen });
expect(screen.getByRole('img', { name: 'Applied' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 9, dayId: null });
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 onOpen = vi.fn();
const first = renderCard([onTheMap('c1', 'Pantheon')], { onOpen });
expect(screen.queryByRole('button', { name: 'View on map' })).toBeNull();
first.unmount();
renderCard([onTheMap('c1', 'Pantheon')], { variant: 'fullscreen', onOpen });
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 onOpen = vi.fn();
const first = renderCard([receipt('c1', null), receipt('c2', null)], { onOpen });
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)], { onOpen });
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,283 @@
import { Check, LoaderCircle, MapPin, Undo2 } 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 AssistantCompareSwitch from './AssistantCompareSwitch';
import AssistantFitCheck from './AssistantFitCheck';
import AssistantWeekOverview from './AssistantWeekOverview';
import { type ChangeItem, undoLeftMs } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { ChangeRows } from './cards/ChangeCardSlots';
import { ICON_BUTTON } from './cards/parts/assistantTones';
import { useChangeExclusions } from './cards/useChangeExclusions';
import { changePoints, type DayChange } from './dayAfterModel';
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. The rows themselves, and a
* change that carries a card of its own, are drawn in cards/ChangeCardSlots.
*
* 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.
*/
/** 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 }]
: []
);
}
/** 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, onOpen } = useAssistantView();
const here = useTripStore((s) => s.trip?.id === tripId);
if (open) {
const toMap = variant === 'fullscreen' && !!onOpen && here && changes.length > 0;
return (
<>
<AssistantFitCheck changes={changes} />
{toMap && <ViewOnMapButton changes={changes} />}
</>
);
}
return (
<>
{onOpen && <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] = useChangeExclusions(items);
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>
)}
<ChangeRows
items={items}
folded={folded}
excluded={excluded}
disabled={isRunning}
undone={undone}
undoable={undoable}
onToggle={toggle}
onUndo={(toolCallId) => void runUndo(toolCallId)}
/>
{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,71 @@
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
import {
CalendarClock,
CalendarDays,
Clock,
Hash,
ListOrdered,
LogIn,
LogOut,
type LucideIcon,
Tag,
Wallet,
} from 'lucide-react';
import { useTranslation } from '../../i18n';
import { FactChange } from './cards/parts/CardFacts';
/**
* The few facts that tell a change apart at a glance, as badges on the 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 (
<>
{facts.map((row, i) => {
const changed = row.op !== 'add' && !!row.before;
return (
<FactChange
key={`${row.labelKey}-${i}`}
icon={KEY_FIELDS[row.labelKey] ?? Tag}
before={changed ? shown(row, row.before, dayWord) : null}
after={shown(row, row.after, dayWord)}
/>
);
})}
</>
);
}
@@ -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,189 @@
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, canAttach } = 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">
{canAttach && (
<>
<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">
<button type="button" className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
{pill}
</button>
</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 = chip.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,39 @@
import type { ResizeEdge, useDragResize } from '../../hooks/useDragResize';
/** The invisible grips of the floating assistant window (assistantFrame.ts) along its edges and corners. */
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 invisible grips along the frame's edges and corners. */
export default function AssistantFrameEdges({ frame }: { frame: ReturnType<typeof useDragResize> }) {
const rtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
return (
<>
{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) }}
/>
))}
</>
);
}
@@ -0,0 +1,224 @@
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 { assistantScopeOf } from '../../store/assistantChatKey';
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. Notes are a trip's: a journey's chat in TREK Studio shows no notes button.
*/
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
const { t, locale } = useTranslation();
const live = useAssistantLiveStep(tripId);
const memory = useTripMemory(tripId);
// Trip notes belong to a trip: a journey's turns never read them (assistant-scope.ts turnOf).
const tripChat = assistantScopeOf(tripId).kind === 'trip';
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()}
>
{tripChat && (
<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,103 @@
/**
* FE-AI-HOST-001 to FE-AI-HOST-007
*
* The desktop host follows the trip on screen and sends what the chat opens
* (a spot on the map, a tab of the trip, another page) and a picked day to
* the planner through its links.
*/
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';
import type { EntityTarget } from './cards/cardTypes';
interface WindowProps {
trip: { id: number; title: string } | null;
onOpen?: (target: EntityTarget) => 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 INITIAL = useTripStore.getState();
afterEach(() => {
useTripStore.setState(INITIAL, true);
seen.props = null;
sessionStorage.clear();
});
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?.onOpen?.({ kind: 'map', placeId: 7, dayId: null }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?place=7');
act(() => seen.props?.onOpen?.({ kind: 'map', placeId: null, dayId: 3 }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=3');
});
it('FE-AI-HOST-006: a tab of the trip opens as ?tab= with its params, a list tab remembers its own list first', () => {
renderAt('/trips/5');
act(() => seen.props?.onOpen?.({ kind: 'tab', tab: 'finanzplan', params: { expense: '7' } }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?tab=finanzplan&expense=7');
act(() => seen.props?.onOpen?.({ kind: 'tab', tab: 'listen', sub: 'todo' }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?tab=listen');
expect(sessionStorage.getItem('trip-lists-subtab-5')).toBe('todo');
});
it('FE-AI-HOST-007: another page is navigated to as it is', () => {
renderAt('/trips/5');
act(() => seen.props?.onOpen?.({ kind: 'page', path: '/atlas' }));
expect(screen.getByTestId('location')).toHaveTextContent('/atlas');
});
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,61 @@
import { useCallback, useMemo } from 'react';
import { useLocation, useMatch, useNavigate } from 'react-router';
import { rememberListsSubTab } from '../../hooks/useListsSubTab';
import { useTripStore } from '../../store/tripStore';
import AssistantWindow from './AssistantWindow';
import { plannerSearchOf } from './assistantShowOnMap';
import type { EntityTarget } from './cards/cardTypes';
/** 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; what a
* card or an applied change opens goes through the planner's links: "Show on
* map" opens the planner on the change (`?place=`, `?day=`), the same way a
* notification opens a place, a tab of the trip opens as `?tab=` (a list's
* own tab remembered first), and another page is simply navigated to. A day
* picked in the chat (its weather) selects that day the same way, and "On
* map" on a card brings the planner's map back first.
*/
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 open = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page') {
void navigate(target.path);
return;
}
if (target.kind === 'tab' && target.tab === 'listen' && tripId !== null) {
if (target.sub === 'packing' || target.sub === 'todo') rememberListsSubTab(tripId, target.sub);
}
openPlanner(plannerSearchOf(target));
},
[navigate, openPlanner, tripId]
);
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
// "On map" from another tab of the planner (Tours, Costs, ...) first brings the map back.
const revealMap = useCallback(() => openPlanner('?tab=plan'), [openPlanner]);
return <AssistantWindow trip={trip} onOpen={open} onFocusDay={focusDay} onRevealMap={revealMap} />;
}
@@ -0,0 +1,145 @@
/**
* FE-AI-LAUNCH-001 to FE-AI-LAUNCH-007
*
* 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, a floating
* button that breathes while a trip's answer is written in a closed window,
* 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 { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, 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();
});
it('FE-AI-LAUNCH-007: the floating button breathes for a trip answer being written, not for one still running in the Studio', () => {
const studio = assistantChatKey({ kind: 'journey', journeyId: 3 });
const chat = (key: number, isRunning: boolean) => ({ ...chatOf(useAssistantStore.getState(), key), isRunning });
useAssistantStore.setState({ status: STATUS, byTrip: { [studio]: chat(studio, true), [5]: chat(5, false) } });
render(<AssistantLauncher variant="floating" />);
const button = screen.getByRole('button', { name: 'Assistant' });
expect(button).not.toHaveAttribute('aria-busy');
act(() => useAssistantStore.setState((s) => ({ byTrip: { ...s.byTrip, [4]: chat(4, true) } })));
expect(button).toHaveAttribute('aria-busy', 'true');
// An open window shows the answer itself.
act(() => useAssistantStore.getState().open());
expect(button).not.toHaveAttribute('aria-busy');
});
});
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,110 @@
import { Sparkles } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { assistantScopeOf } from '../../store/assistantChatKey';
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. Only a
// trip's chat counts: an answer still running in the Studio is not one this window opens on.
const busy = useAssistantStore((s) =>
!s.window.open || s.window.minimized
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind === 'trip')
: 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,165 @@
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 AssistantCardBlock from './cards/AssistantCardBlock';
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, the cards its
* tools brought 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} />;
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
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,248 @@
/**
* FE-AI-PLACES-001 to FE-AI-PLACES-010
*
* 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, the map's part, and
* no "+ Day" in a journey's chat.
*/
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 { assistantChatKey } from '../../store/assistantChatKey';
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);
});
it('FE-AI-PLACES-010: a journey chat lists the places but offers no "+ Day" while the trip is on screen', () => {
// The journey key never matches the planner's trip, so the trip check already hides "+ Day";
// the scope check in the hook is defence in depth for the day trips and journeys share keys.
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 }, answer(), {
tripId: assistantChatKey({ kind: 'journey', journeyId: TRIP }),
});
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Add to/ })).toBeNull();
expect(screen.queryByRole('button', { name: /Pick a day/ })).toBeNull();
});
});
@@ -0,0 +1,40 @@
import type { AssistantMessage, AssistantPlaces } from '@trek/shared';
import { useTranslation } from '../../i18n';
import AssistantPlaceCard from './AssistantPlaceCard';
import { suggestedFromStrip } from './assistantParts';
import CardStrip from './cards/parts/CardStrip';
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;
/**
* The places a search found, as one row of cards to swipe or scroll through
* (the cards' strip: the next card peeks in, the edges fade where there is
* more, and the desktop head names the row and pages it by whole cards).
* 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 strip = useAssistantPlaceStrip();
const rest = Math.max(0, found.places.length - SHOWN) + found.more;
const total = found.places.length + found.more;
return (
<CardStrip label={t('assistant.place.strip')} count={total} watch={found}>
{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>
)}
</CardStrip>
);
}
@@ -0,0 +1,260 @@
/**
* 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',
card: null,
});
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,75 @@
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 { useAssistantScope } from './assistantViewContext';
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.
* In TREK Studio the chat is about a journey and its book, so the banner
* speaks of those; a picture of a page has the Studio's own notice.
*/
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);
// The Studio's chat reads a journey, never a trip, and may show pages once the user allows it.
const book = useAssistantScope().kind === 'journey';
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">
{book ? t('assistant.privacy.bannerBook', { provider }) : t('assistant.privacy.banner', { provider })}
</p>
</div>
{details && (
<ul className="m-0 mt-2 list-disc space-y-1 ps-9">
<li>{book ? t('assistant.privacy.sharedBook') : t('assistant.privacy.shared')}</li>
<li>{book ? t('assistant.privacy.notSharedBook') : 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,260 @@
/**
* FE-AI-SURF-001 to FE-AI-SURF-009
*
* 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, the composer holding back while a change
* waits for a decision, and a journey's chat under its own key, without the
* trip's questions and booking upload, read only for a viewer of the
* journey and in the book's words.
*/
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 { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { type JourneyDetail, useJourneyStore } from '../../store/journeyStore';
import AssistantSurface from './AssistantSurface';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const JOURNEYS = useJourneyStore.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);
useJourneyStore.setState(JOURNEYS, 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();
});
it('FE-AI-SURF-007: a journey chat reads and sends under its own key, not the trip with the same id', async () => {
const key = assistantChatKey({ kind: 'journey', journeyId: TRIP });
useAssistantStore.setState({
status: STATUS,
send,
byTrip: {
[TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), draft: 'trip draft' },
[key]: {
...chatOf(useAssistantStore.getState(), key),
messages: [
{
id: 'q1',
role: 'user',
status: 'complete',
createdAt: AT,
parts: [{ type: 'text', text: 'Tidy page 3' }],
},
],
},
},
});
render(<AssistantSurface tripId={key} tripTitle="Lisbon" variant="window" density="compact" />);
expect(await screen.findByText('Tidy page 3')).toBeInTheDocument();
const input = screen.getByRole('textbox', { name: 'Ask anything about Lisbon' });
fireEvent.change(input, { target: { value: 'Calmer cover' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(send).toHaveBeenCalledWith(key, 'Calmer cover'));
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('trip draft');
});
it('FE-AI-SURF-008: an empty journey chat offers no trip questions, no booking upload and no drop zone', async () => {
const key = assistantChatKey({ kind: 'journey', journeyId: TRIP });
useAssistantStore.setState({
status: STATUS,
send,
byTrip: { [key]: { ...chatOf(useAssistantStore.getState(), key), messages: [] } },
});
render(<AssistantSurface tripId={key} tripTitle="Lisbon" variant="window" />);
const input = await screen.findByRole('textbox', { name: 'Ask anything about Lisbon' });
// It asks about the book, not the plan.
expect(screen.getByText('What should the book look like?')).toBeInTheDocument();
expect(screen.queryByText('What are we planning?')).toBeNull();
// An owner of the journey, as the Studio reads one before the journey says otherwise.
expect(screen.queryByText(/You can only view/)).toBeNull();
expect(screen.queryByRole('button', { name: 'Which days are still empty?' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Attach a booking' })).toBeNull();
// A file dragged over the chat is left to the browser: nothing lights up to take it.
fireEvent.dragEnter(input, { dataTransfer: { types: ['Files'], files: [] } });
expect(screen.queryByText('Drop it here and I will read the booking')).toBeNull();
});
it('FE-AI-SURF-009: a viewer of the journey hears in the book words that the chat changes nothing', async () => {
const key = assistantChatKey({ kind: 'journey', journeyId: TRIP });
useAssistantStore.setState({
status: STATUS,
send,
byTrip: { [key]: { ...chatOf(useAssistantStore.getState(), key), messages: [] } },
});
useJourneyStore.setState({ current: { id: TRIP, my_role: 'viewer' } as JourneyDetail });
render(<AssistantSurface tripId={key} tripTitle="Lisbon" variant="window" />);
expect(
await screen.findByText('You can only view this book, so I can answer questions but not change anything.')
).toBeInTheDocument();
expect(screen.queryByText(/this trip/)).toBeNull();
});
});
@@ -0,0 +1,54 @@
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: the trip's title, or the journey's in TREK Studio. */
tripTitle: string;
}
/**
* The chat for one trip, the same on the desktop window and the phone screen,
* and for one journey in TREK Studio: `tripId` is the chat's key, so the
* Studio passes `assistantChatKey({ kind: 'journey', journeyId })` and gets
* the journey's conversations, with the parts that need a trip left empty.
* 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,
onOpen,
onFocusDay,
onRevealMap,
bookVersion,
}: 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'),
onOpen,
onFocusDay,
onRevealMap,
bookVersion,
}),
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion]
);
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,89 @@
import { ThreadPrimitive } from '@assistant-ui/react';
import { useTranslation } from '../../i18n';
import EmptyState from '../shared/EmptyState';
import AssistantHints, { MAX_VISIBLE_HINTS } from './AssistantHints';
import { useAssistantScope, 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();
// The Studio's chat is about a book, not a plan.
const book = useAssistantScope().kind === 'journey';
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={book ? t('journey.studio.assistant.welcome') : t('assistant.empty.title')}
/>
{!canEdit && (
<p className="m-0 max-w-xs text-center text-caption text-content-muted">
{book ? t('journey.studio.assistant.readOnly') : 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,250 @@
/**
* FE-AI-WIN-001 to FE-AI-WIN-012
*
* 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; a trip's head
* offers the notes its turns read. The geometry is
* useDragResize's and has its own tests; here it is a fixed rect.
*/
import type { AiStatusResponse } from '@trek/shared';
import { useLocation } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, 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';
import type { EntityTarget } from './cards/cardTypes';
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,
onOpen,
}: {
tripId: number;
tripTitle: string;
density?: string;
onFocusDay?: (dayId: number) => void;
onOpen?: (target: EntityTarget) => void;
}) => (
<div>
<p>
chat {tripId} {tripTitle}
</p>
<output data-testid="density">{density}</output>
{onFocusDay && (
<button type="button" onClick={() => onFocusDay(12)}>
pick day
</button>
)}
{onOpen && (
<>
<button type="button" onClick={() => onOpen({ kind: 'map', placeId: 7, dayId: null })}>
open place
</button>
<button type="button" onClick={() => onOpen({ kind: 'tab', tab: 'finanzplan', params: { expense: '3' } })}>
open tab
</button>
<button type="button" onClick={() => onOpen({ kind: 'page', path: '/atlas' })}>
open page
</button>
</>
)}
<textarea aria-label="composer" />
</div>
),
}));
const TRIP = { id: 4, title: 'Kyoto' };
function Location() {
const { pathname, search } = useLocation();
return <output data-testid="location">{`${pathname}${search}`}</output>;
}
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(() => {
// Unmounted before the store gets its real actions back: a window still on screen would ask the real
// loadStatus, whose failed request lands in a later case and hides the assistant there (status null).
cleanup();
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-012: a trip head offers the trip notes, which every turn on the trip reads', () => {
seed({ open: true, minimized: false });
render(<AssistantWindow trip={TRIP} />);
expect(screen.getByRole('button', { name: 'Trip notes' })).toBeInTheDocument();
});
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();
});
it('FE-AI-WIN-011: opens through the planner on screen, keeps a trip it leaves, and opens a picked trip itself', async () => {
seed({ open: true, minimized: false });
const onOpen = vi.fn();
const onScreen = render(<AssistantWindow trip={TRIP} onOpen={onOpen} />);
fireEvent.click(screen.getByRole('button', { name: 'open place' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 7, dayId: null });
// Another page leaves the trip; the chat stays on it as if it had been picked.
fireEvent.click(screen.getByRole('button', { name: 'open page' }));
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'page', path: '/atlas' });
onScreen.rerender(<AssistantWindow trip={null} onOpen={onOpen} />);
expect(screen.getByText('chat 4 Kyoto')).toBeInTheDocument();
onScreen.unmount();
seed({ open: true, minimized: false });
render(
<>
<AssistantWindow trip={null} onOpen={onOpen} />
<Location />
</>
);
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
fireEvent.click(screen.getByRole('button', { name: 'open tab' }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/9?tab=finanzplan&expense=3');
fireEvent.click(screen.getByRole('button', { name: 'open place' }));
expect(screen.getByTestId('location')).toHaveTextContent(/^\/trips\/9$/);
fireEvent.click(screen.getByRole('button', { name: 'open page' }));
expect(screen.getByTestId('location')).toHaveTextContent('/atlas');
expect(onOpen).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,260 @@
import './assistant.css';
import { Sparkles, X } from 'lucide-react';
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import type { WindowRect } from '../../hooks/useDragResize';
import { useTranslation } from '../../i18n';
import { useAssistantStore } from '../../store/assistantStore';
import AssistantFrameEdges from './AssistantFrameEdges';
import AssistantHeader from './AssistantHeader';
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
import AssistantSurfaceLazy from './AssistantSurfaceLazy';
import AssistantTripPicker from './AssistantTripPicker';
import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
import { plannerSearchOf } from './assistantShowOnMap';
import type { EntityTarget } from './cards/cardTypes';
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;
/** Opens what a card or an applied change points at, from the trip on screen. */
onOpen?: (target: EntityTarget) => void;
/** Selects a day in the planner on screen. */
onFocusDay?: (dayId: number) => void;
/** Brings the planner's map on screen, from whichever of its tabs is open. */
onRevealMap?: () => 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';
/** 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,
onOpen,
onFocusDay,
onRevealMap,
}: {
trip: WindowTrip;
hidden: boolean;
onOpen?: (target: EntityTarget) => void;
onFocusDay?: (dayId: number) => void;
onRevealMap?: () => 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);
const frame = useAssistantFrame(WINDOW_STORAGE);
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 ${FRAME_CLASS} ${hidden ? 'hidden' : 'flex'} ${
frame.active ? 'select-none' : 'trek-assistant-settle'
}`}
>
<AssistantFrameEdges frame={frame} />
<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'}
onOpen={onOpen}
onFocusDay={onFocusDay}
onRevealMap={onRevealMap}
/>
</div>
</section>
);
}
export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap }: 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;
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
const openPicked = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page') void navigate(target.path);
else if (picked) void navigate(`/trips/${picked.id}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
},
[navigate, picked]
);
// Another page leaves the trip: the chat stays on it, as if it had been picked.
const openOnScreen = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page' && trip) setPicked(trip);
onOpen?.(target);
},
[onOpen, trip]
);
if (!available) return null;
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
if (!current) return <PickerWindow onPick={setPicked} />;
const onScreen = !!trip && current.id === trip.id;
return (
<>
<ChatWindow
trip={current}
hidden={minimized}
onOpen={onScreen ? onOpen && openOnScreen : openPicked}
// Only the planner on screen has days to select and a map to bring back.
onFocusDay={onScreen ? onFocusDay : undefined}
onRevealMap={onScreen ? onRevealMap : 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,116 @@
/**
* FE-AI-FOLLOW-001 to FE-AI-FOLLOW-007
*
* The follow-ups a card offers under an answer that ends on it: the ones that
* ask for a change only for someone who may make it, the answer's own when
* none is left for a reader, and the ones with a condition only when a card of
* the answer meets it. The questions the model offered win over all of them; a
* change asks what its own kind offers, the day plan's questions come only
* after a change to the day plan, and an answer that asks the user something
* offers no "tell me more".
*/
import type { AssistantChangePreview, AssistantMessage, TrekCard } from '@trek/shared';
import { afterEach, describe, expect, it } from 'vitest';
import { followUpsFor } from './assistantFollowUpRules';
import { CARD_LOOKS } from './cards/cardLooks';
const answer: AssistantMessage = {
id: 'a1',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-22T08:00:00.000Z',
parts: [
{ type: 'text', text: 'Here is your year.' },
{ type: 'tool-call', toolCallId: 'r1', toolName: 'get_vacay_plan', args: {}, result: {} },
{ type: 'data-trek-card', data: { toolCallId: 'r1', card: { kind: 'vacay' } as TrekCard } },
],
};
afterEach(() => {
delete CARD_LOOKS.vacay.followUps;
});
describe('card follow-ups', () => {
it('FE-AI-FOLLOW-001: a follow-up that asks for a change is offered only to someone who may make it', () => {
CARD_LOOKS.vacay.followUps = [
{ id: 'planDays', labelKey: 'assistant.followUp.next', edits: true },
{ id: 'holidays', labelKey: 'assistant.followUp.more' },
];
expect(followUpsFor(answer, true).map((f) => f.id)).toEqual(['planDays', 'holidays']);
expect(followUpsFor(answer, false).map((f) => f.id)).toEqual(['holidays']);
});
it('FE-AI-FOLLOW-002: with no card follow-up left for a reader, the answer offers its own', () => {
CARD_LOOKS.vacay.followUps = [{ id: 'planDays', labelKey: 'assistant.followUp.next', edits: true }];
expect(followUpsFor(answer, false).map((f) => f.id)).toEqual(['more', 'next']);
});
it('FE-AI-FOLLOW-003: a follow-up with a condition is offered only when a card of the answer meets it', () => {
const asked: TrekCard[] = [];
CARD_LOOKS.vacay.followUps = [
{ id: 'planDays', labelKey: 'assistant.followUp.next', when: () => false },
{
id: 'holidays',
labelKey: 'assistant.followUp.more',
when: (card) => {
asked.push(card);
return true;
},
},
];
expect(followUpsFor(answer, true).map((f) => f.id)).toEqual(['holidays']);
expect(asked).toEqual([{ kind: 'vacay' }]);
});
});
/** An answer of the given parts. */
const of = (parts: AssistantMessage['parts']): AssistantMessage => ({ ...answer, parts });
/** A proposed change, with the card of what it does. */
const change = (card?: TrekCard): AssistantChangePreview => ({
toolCallId: 'c1',
verbKey: 'addPlace',
subject: 'Something',
rows: [],
dayId: null,
placeIds: [],
undoable: true,
...(card ? { card } : {}),
});
describe('the questions an answer offers', () => {
it('FE-AI-FOLLOW-004: the questions the model offered win, sent as written, the last offer only', () => {
const offered = of([
{ type: 'text', text: 'Done.' },
{ type: 'data-trek-follow-ups', data: { questions: ['Old'] } },
{ type: 'data-trek-follow-ups', data: { questions: ['An easier variant', 'What to pack?'] } },
]);
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['An easier variant', 'What to pack?']);
});
it('FE-AI-FOLLOW-005: a proposed tour asks about the tour, never to plan the next day the same way', () => {
const tour = { kind: 'tourRoute', source: 'proposal', activity: 'hike' } as TrekCard;
const ids = followUpsFor(of([{ type: 'data-trek-change', data: change(tour) }]), true).map((f) => f.id);
expect(ids).toContain('tourEasier');
expect(ids).not.toContain('nextDay');
});
it('FE-AI-FOLLOW-006: an applied expense offers the general questions, an applied stop the day plan', () => {
const applied = (entity: 'budget' | 'assignment') =>
of([
{ type: 'text', text: 'Added.' },
{
type: 'data-trek-applied',
data: { toolCallId: 'c1', entity, ids: [1], dayId: null, undoUntil: null },
},
]);
expect(followUpsFor(applied('budget'), true).map((f) => f.id)).toEqual(['more', 'next']);
expect(followUpsFor(applied('assignment'), true).map((f) => f.id)).toContain('nextDay');
});
it('FE-AI-FOLLOW-007: an answer that asks the user something waits for their answer, not for "tell me more"', () => {
expect(followUpsFor(of([{ type: 'text', text: 'Should I plan it anyway?' }]), true)).toEqual([]);
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
});
});
@@ -0,0 +1,136 @@
import type { AssistantMessage, TrekCard, TrekCardKind } from '@trek/shared';
import { messageBlocks } from './assistantParts';
import { CARD_LOOKS } from './cards/cardLooks';
import type { CardEntry, CardFollowUp, CardLook } from './cards/cardTypes';
/**
* The next questions an answer suggests, as one tap each. The model offers
* them itself at the end of an answer, fitting the conversation; an answer
* without them falls back to what it holds, which costs nothing and comes at
* once. Cards ask what their kind offers, places found ask to narrow them, a
* change asks what its own kind offers (a tour about the tour) and a change
* to the day plan asks to go on with the plan, a forecast asks for the other
* weather. Three at most; the first kind the answer holds wins.
*/
export interface FollowUp {
id: string;
/** What the chip says and sends, translated; a question the model wrote carries `text` instead. */
labelKey: string;
/** The model's own question, sent as it is. */
text?: string;
/** Asks for a change, so only someone who may edit the trip is offered it. */
edits?: boolean;
}
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 a change touches that the day plan's questions ("plan the next day the same way") are about. */
const PLAN_ENTITIES: ReadonlySet<string> = new Set(['place', 'assignment', 'day_note']);
/** What a kind offers to ask next, typed for any kind. */
function offeredBy(kind: TrekCardKind): readonly CardFollowUp<TrekCardKind>[] {
return (CARD_LOOKS[kind] as CardLook<TrekCardKind>).followUps ?? [];
}
/**
* The follow-ups of the cards an answer ends on (past its closing words): what
* the first card's kind offers, laid out as the chat lays it out, without the
* ones that ask for a change when the user may not make one, and without the
* ones none of the answer's cards of that kind calls for.
*/
function cardFollowUps(message: AssistantMessage, canEdit: boolean): readonly FollowUp[] {
const blocks = messageBlocks(message, false).filter((block) => block.kind !== 'text');
const last = blocks[blocks.length - 1];
const entries: readonly CardEntry[] = last?.kind === 'cards' ? last.entries : [];
const kind = entries[0]?.card.kind;
if (!kind) return [];
const cards = entries.filter((entry) => entry.card.kind === kind).map((entry) => entry.card);
return offeredBy(kind).filter(
(followUp) => (canEdit || !followUp.edits) && (!followUp.when || cards.some((card) => followUp.when?.(card)))
);
}
/**
* The follow-ups of the changes an answer proposed or made: what the first
* change card's kind offers (a tour's are about the tour), else the day
* plan's questions when a change touched the day plan, else none, so a
* budget entry or a packing list is not asked to plan the next day the same way.
*/
function changeFollowUps(message: AssistantMessage, canEdit: boolean): readonly FollowUp[] | null {
const cards: TrekCard[] = [];
const entities = new Set<string>();
let changed = false;
for (const part of message.parts) {
if (part.type === 'data-trek-change') {
changed = true;
if (part.data.card) cards.push(part.data.card);
} else if (part.type === 'data-trek-applied') {
changed = true;
entities.add(part.data.entity);
}
}
if (!changed) return null;
const kind = cards[0]?.kind;
if (kind) {
const offered = offeredBy(kind).filter(
(followUp) =>
(canEdit || !followUp.edits) &&
(!followUp.when || cards.some((card) => card.kind === kind && followUp.when?.(card)))
);
if (offered.length > 0) return offered;
}
if (!canEdit) return null;
const onPlan = entities.size === 0 ? !kind : [...entities].some((entity) => PLAN_ENTITIES.has(entity));
return onPlan ? PLAN : [];
}
/** Whether the answer's last words ask the user something. */
function endsWithQuestion(message: AssistantMessage): boolean {
const texts = message.parts.flatMap((part) => (part.type === 'text' ? [part.text] : []));
return /[??]\s*$/.test(texts[texts.length - 1] ?? '');
}
/** The questions the model offered at the end of the answer, the last offer's; none when it offered none. */
function modelFollowUps(message: AssistantMessage): readonly FollowUp[] {
const offers = message.parts.flatMap((part) => (part.type === 'data-trek-follow-ups' ? [part.data] : []));
const offer = offers[offers.length - 1];
if (!offer) return [];
return offer.questions.map((text, index) => ({ id: `model-${index}`, labelKey: '', text }));
}
/** 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 fromModel = modelFollowUps(message);
if (fromModel.length > 0) return fromModel;
const fromCards = cardFollowUps(message, canEdit);
if (fromCards.length > 0) return fromCards;
const fromChanges = changeFollowUps(message, canEdit);
if (fromChanges !== null) return fromChanges.length > 0 ? fromChanges : GENERAL;
const types = new Set(message.parts.map((part) => part.type));
if (types.has('data-trek-places')) return PLACES;
if (types.has('data-trek-weather')) return WEATHER;
// An answer that asks the user something waits for their answer, not for "tell me more".
return types.has('text') && !endsWithQuestion(message) ? GENERAL : [];
}
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { type WindowSize, useDragResize } from '../../hooks/useDragResize';
/**
* The floating window's frame, shared by the planner's window and TREK
* Studio's: moved by its head, resized from any edge or corner, kept inside
* the screen and below the navbar, remembered under its own key.
*/
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),
});
/** 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;
}
/** The frame of a floating assistant window, its place and size kept under `storageKey`. */
export function useAssistantFrame(storageKey: string, topInsetPx?: number) {
// The navbar sits on the same layer and later in the page: the head never goes under it.
const [topInset] = useState(() => topInsetPx ?? navbarHeight());
return useDragResize({
storageKey,
minSize: MIN_SIZE,
defaultSize: DEFAULT_SIZE,
expandedSize,
margin: MARGIN,
gap: GAP,
topInset,
});
}
/** The glass window's own classes; `active` while it is being moved or resized. */
export const FRAME_CLASS =
'fixed z-[var(--z-nav)] flex-col overflow-visible rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150';
@@ -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,208 @@
/**
* FE-AI-MODEL-001 to FE-AI-MODEL-012
*
* 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-011: names the expense, share, wind, track and Dawarich reads by what they look at', () => {
expect(
[
'list_budget_items',
'get_budget_per_person',
'get_wind_forecast',
'get_journey_tracks',
'scan_bucket_list_visits',
'suggest_atlas_countries',
].map(toolStepKey)
).toEqual([
'assistant.step.expenses',
'assistant.step.shares',
'assistant.step.wind',
'assistant.step.tracks',
'assistant.step.bucketScan',
'assistant.step.atlasCountries',
]);
});
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' });
});
it('FE-AI-MODEL-012: a hike being planned says so, and names its goal once the step names one', () => {
expect(toolStepKey('propose_hike')).toBe('assistant.step.planningHike');
expect(liveStepText('propose_hike', 'Herzogstand')).toEqual({
key: 'assistant.step.planningHikeTo',
params: { place: 'Herzogstand' },
});
expect(liveStepText('propose_hike', null)).toEqual({ key: 'assistant.step.planningHike' });
});
});
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,260 @@
import type { ThreadMessageLike } from '@assistant-ui/react';
import type { AiStatusResponse, AssistantErrorCode, AssistantMessage, AssistantMessagePart } from '@trek/shared';
import { STEP_LOOKS } from './cards/cardLooks';
/**
* 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 (the steps live in
* the cards' look), 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 ──────────────────────────────────────────────────────────────
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 STEP_LOOKS[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 = STEP_LOOKS[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,423 @@
/**
* FE-AI-PARTS-001 to FE-AI-PARTS-019
*
* How an answer is laid out: text, the steps the assistant looked up, the
* places and the weather they found, the cards its tools brought, and one
* card per batch of proposed changes, each change in the state its approval
* and receipt put it in, with its own card.
*/
import {
ASSISTANT_CHANGE_LABEL_KEYS,
type AssistantApplied,
type AssistantChangePreview,
type AssistantFoundPlace,
type AssistantMessage,
type AssistantMessagePart,
type AssistantWeatherDay,
type TrekCard,
} from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
CHANGE_LABELS,
type CardRules,
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('messageBlocks: cards', () => {
const ROUTE = { kind: 'tourRoute' } as TrekCard;
const LIST = { kind: 'tourList' } as TrekCard;
const card = (toolCallId: string, value: TrekCard | null): AssistantMessagePart => ({
type: 'data-trek-card',
data: { toolCallId, card: value },
});
it('FE-AI-PARTS-015: a read card joins the cards of its kind across steps, and the steps stay one row', () => {
const blocks = messageBlocks(
message([read('r1', {}), card('r1', LIST), read('r2', {}), card('r2', LIST), read('r3', {}), card('r3', ROUTE)]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'cards', 'cards']);
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 'r1' }, { toolCallId: 'r2' }, { toolCallId: 'r3' }] });
expect(blocks[1]).toMatchObject({ group: 'tourList', entries: [{ toolCallId: 'r1' }, { toolCallId: 'r2' }] });
expect(blocks[2]).toMatchObject({ group: 'tourRoute', entries: [{ toolCallId: 'r3', card: ROUTE }] });
});
it('FE-AI-PARTS-016: text between cards starts a new block; a card that did not read leaves nothing', () => {
const blocks = messageBlocks(
message([card('r1', LIST), { type: 'text', text: 'And more:' }, card('r2', LIST), card('r3', null)]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['cards', 'text', 'cards']);
expect(blocks[2]).toMatchObject({ entries: [{ toolCallId: 'r2' }] });
});
it('FE-AI-PARTS-017: a change carries its preview card until the applied card replaces it, and forms no block', () => {
const held = { ...preview('w1'), card: ROUTE };
const before = messageBlocks(message([write('w1'), { type: 'data-trek-change', data: held }]), false);
expect(before).toHaveLength(1);
expect(before[0]).toMatchObject({ kind: 'change', items: [{ toolCallId: 'w1', card: ROUTE }] });
const after = messageBlocks(
message([
write('w1', { approved: true }, { result: {} }),
{ type: 'data-trek-change', data: held },
{ type: 'data-trek-applied', data: applied('w1') },
card('w1', LIST),
]),
false
);
expect(after.map((b) => b.kind)).toEqual(['change']);
expect(after[0]).toMatchObject({ items: [{ toolCallId: 'w1', state: 'applied', card: LIST }] });
const plain = messageBlocks(message([write('w2'), { type: 'data-trek-change', data: preview('w2') }]), false);
expect(plain[0]).toMatchObject({ items: [{ card: null }] });
});
it('FE-AI-PARTS-018: the rules group cards, and a lookup card that served a change gives way to it', () => {
const rules: CardRules = {
group: () => 'tours',
yieldsToChange: (answer, change) => answer.kind === 'tourList' && change.kind === 'tourRoute',
};
const parts: AssistantMessagePart[] = [
card('r1', LIST),
card('r2', ROUTE),
write('w1'),
{ type: 'data-trek-change', data: { ...preview('w1'), card: ROUTE } },
];
const blocks = messageBlocks(message(parts), false, rules);
expect(blocks.map((b) => b.kind)).toEqual(['cards', 'change']);
expect(blocks[0]).toMatchObject({ group: 'tours', entries: [{ toolCallId: 'r2' }] });
// Without a change card nothing gives way; with the plain rules each kind is its own group.
const plain = messageBlocks(message(parts.slice(0, 2)), false);
expect(plain.map((b) => (b.kind === 'cards' ? b.group : b.kind))).toEqual(['tourList', 'tourRoute']);
});
it('FE-AI-PARTS-019: a block left with no card goes, and a step after the cards joins the steps before them', () => {
const rules: CardRules = { group: (c) => c.kind, yieldsToChange: () => true };
const blocks = messageBlocks(
message([
read('r1', {}),
card('r1', LIST),
read('r2', {}),
write('w1'),
{ type: 'data-trek-change', data: { ...preview('w1'), card: ROUTE } },
]),
false,
rules
);
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'change']);
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 'r1' }, { toolCallId: 'r2' }] });
});
});
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}`);
}
});
});

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