Compare commits

..

240 Commits

Author SHA1 Message Date
Maurice 3c73da4427 fix(assistant): bring the end of a long answer into view once it is written
A new question is held at the top of the view, so its answer reads from its
first line. When the answer carries steps, a strip of places and cards, its
closing words and the changes to apply ended below the view, and the user
saw only the places found. Once the answer is written, the thread now
scrolls to where those closing blocks begin, or to the bottom when they are
shorter than the view, unless the user scrolled it by hand meanwhile.
2026-10-24 16:33:14 +02:00
Maurice cff2e55db7 feat(help): Studio's help as Studio is now, with guides for themes, stickers, travel, text effects, backgrounds, layers and own layouts
The screen texts name the top bar, the rail and the workbench as they are
after the redesign, the travel guide walks through the four tabs, and six
new guides cover what Studio gained since its help was written. Every
Studio picture is taken again, and all texts are translated.
2026-10-24 15:38:36 +02:00
Maurice 429a25faa9 fix(admin): name the addon columns by start and end 2026-10-24 15:10:13 +02:00
Maurice 486a2eeca3 feat(admin): addons tab in the cards of the other tabs, with a search and an on/off filter
The addons sit in a card per type now, the way every other admin tab sets
its cards, instead of three columns nested in one card. A head card shows
how many of each type are on and finds an addon by its name or description,
with a switch between all, the ones on and the ones off.
2026-10-24 15:10:13 +02:00
Maurice 26a6147e8d fix(book): a summary's and a country's look are optional, so a stored book reads and saves as it did
Absent means the classic look. Defaulting it wrote the field into every
element the auto layout and the templates build, which moved their parity
fixtures and grew the page tools past their schema ceiling; the new
descriptions are also shorter for the same reason.
2026-10-24 14:59:00 +02:00
Maurice a7b53988ff feat(studio): rethink the travel tab with many new looks, a saved tile and a newer-book overlay
The travel tab is four tabs now, figures, maps, countries and marks, and
offers only the newer looks, each dressed in the book's theme and linked to
it. The looks books already hold still draw on their pages exactly as
before; they are just no longer offered.

- Figures: a summary as a poster, a ticket, tiles, a road sign, an
  odometer, a handwritten note, a pencil sketch or a band, and each figure
  on its own as a medal, on odometer drums or ringed by hand.
- Maps: the route with its photographs, its stops numbered, its two ends
  marked or on a dark ground, over satellite or relief imagery, a map per
  trip, and a map of each country with its stops alone, cut to its shape.
- Countries: each country as a passport stamp, a visa, an airmail
  postcard, its flag in cloth, a map pin, a luggage label or a sketch.
- Marks: the date, the day, the distance, the coordinates and the flag as
  stamps, tags, pills, ribbons, die-cut stickers, embroidered patches and
  sketches.

The schema gains a summary's and a country's look, seven mark styles and
the numbered and ends stops of a map, all additive, as revision 11.

Also in Studio: the save state is a tile beside who is here, a book a newer
Studio saved is said by an overlay that holds the whole Studio until it
reloads, and the page rail draws the first and the last page beside the
blank inside of their cover. The notifications page and the bell's panel
take the dialogs' look: a head with a tile, cards with an unread dot, and
tools that come forward on hover.
2026-10-24 14:45:04 +02:00
Maurice 3f3e2f0bcf chore(client): hold the refs count and the stylesheet size at what they are 2026-10-24 14:06:40 +02:00
Maurice f956ab07f3 fix(studio): tidy the head row, the layouts tab and the theme panel
Every piece of the head row is one raised tile of one height: the format
picker moved beside the tools, the assistant and help stand alone, the
close button is square like the rest, and the beta mark is gone. The
layouts tab lays the book's own and the built-in layouts out in sections
and in the same grid as the designed pages. In the theme panel the accent
dots sit in the sheet's corner, the three accents share one name, a font
keeps its name ahead of its kind, a doubled rule is gone, and the
inspector's empty hint is shorter.
2026-10-24 13:54:49 +02:00
Maurice 736594696e fix(tours): one summit per mountain in the places along a tour
The index holds a mountain as many pins named by whoever listed it, at its
foot as much as on top, so a hike up Mt. Takao listed it five times under
names like Mt.Takao, 東京都 高尾山 and 高尾山 (Mt. Takao). Rows that are one
place now merge as a group: like names in one spot for every kind, and for a
summit of the index like names anywhere on the mountain, linked through a
name that carries both scripts. The row kept is an OpenStreetMap object over
an index pin, then the plainest name. Two OpenStreetMap summits stay two.
2026-10-24 13:54:48 +02:00
Maurice 1be997f7d8 test(studio): the pattern picker test counts the tiles against BOOK_PATTERNS 2026-10-24 11:36:30 +02:00
Maurice 5a8ff16afa test(studio): the shape swap test finds the ellipse by its translated name 2026-10-24 11:28:13 +02:00
Maurice 4435dc32b8 refactor(studio): gradient stops built by a plain function; the letters' fill in a helper 2026-10-24 11:07:16 +02:00
Maurice f5a33e0c1e refactor(studio): a paint's SVG parts in a module of their own; the pattern letters a component of their own 2026-10-24 11:07:16 +02:00
Maurice a8326b6f5a chore(client): the shape name entry at the end of the dynamic key list, beside the sticker one 2026-10-24 11:07:16 +02:00
Maurice 1e999d968a fix(studio): the new stickers drawn with the path commands the catalogue takes; the text fill test without Array.at 2026-10-24 11:07:16 +02:00
Maurice d0f74c9f02 feat(studio): add_book_pages, set_book_cover and design_book's cover take the decorated templates too 2026-10-24 11:07:16 +02:00
Maurice f14f1e41b9 fix(studio): bold text measured at each family's own bold width, so a bold condensed heading no longer overruns its frame 2026-10-24 11:07:16 +02:00
Maurice 9138e722bb feat(studio): twelve new stickers: Parthenon, Milan Cathedral, canal houses, gondola, Lisbon tram, Celtic harp, tiger, monkey, camel and three doodles 2026-10-24 11:07:16 +02:00
Maurice 9695e269e3 fix(studio): shapes named in every language, the align target kept across selections, up and down in a tab grid 2026-10-24 11:07:16 +02:00
Maurice 82bdf62c7b feat(studio): linen and paper grain, two quiet textures among the patterns 2026-10-24 11:07:15 +02:00
Maurice 8893e3afe0 feat(studio): letters filled with a gradient or a pattern 2026-10-24 11:07:15 +02:00
Maurice cd1e7d8fa8 refactor(pdf): call the markdown row directly 2026-10-24 10:39:36 +02:00
Maurice 059e72df69 refactor(pdf): the markdown rows and styles in a module of their own; the shared stop's markdown test beside its component 2026-10-24 10:39:36 +02:00
Maurice dc519b3ea7 fix(journey): a journey's created_at is an epoch number 2026-10-24 10:39:36 +02:00
Maurice ad4074f107 fix: journey year helper takes what a journey row carries; PDF markdown test without casts 2026-10-24 10:39:36 +02:00
Maurice 0534d7b3a7 fix: test strings, logical sides in the PDF markdown styles, one export per markdown module 2026-10-24 10:39:36 +02:00
Maurice a0554c2346 fix: four reported bugs that dev still had
- Dashboard: a browser that names no time zone (Chrome on Windows for a zone
  it cannot map) crashed the world clocks and the whole dashboard. One helper
  now reads the browser zone and every reader picks its fallback; a stored
  zone the browser does not know is left out instead of throwing (#2694).
- Phone: a leg of a multi-leg train or flight showed only its route, and its
  sheet showed the first leg's seat. The day plan subtitle is one helper for
  desktop and phone now, so a leg names its own train or flight, platform and
  seat, translated instead of the hardcoded "Gl."/"Sitz"; the sheet opened
  from a leg shows that leg's number, seat, platform and times (#2701). The
  PDF export translates platform and seat too.
- Journey list: the year badge named the year a journey was written, not the
  years of its trips; a trip over New Year shows both (#2685).
- A place's description and notes are markdown in the planner but printed as
  raw syntax in the PDF export and on a shared trip. Both render it now through
  the app's sanitized markdown pipeline, raw HTML stays text (#2692).

Closes #2694
Closes #2701
Closes #2685
Closes #2692
2026-10-24 10:39:35 +02:00
Maurice bc5af23896 chore(client): lower the test non-null assertion baseline to what the tree holds 2026-10-24 10:08:00 +02:00
Maurice 92be8a7ca5 fix(studio): lint and test fixes: zoom bar labels without the studio object, tile zooms in a helper, tests without non-null assertions or the window toast 2026-10-24 10:07:59 +02:00
Maurice 44ea1ea0ba fix(places): search_place asks for one place per query, so the assistant stops bundling four names into one search 2026-10-24 10:07:59 +02:00
Maurice 3bee600096 fix(assistant): a step that searched the web and called a tool no longer sends ChatGPT an answer to the search, which it refused 2026-10-24 10:07:59 +02:00
Maurice 5b4030f86b fix(studio): the full page notice names no number, so it needs no plural forms 2026-10-24 10:07:59 +02:00
Maurice e2c00ba18b feat(studio): a full page says so instead of swallowing the click 2026-10-24 10:07:59 +02:00
Maurice 1050029842 fix(studio): a saved layout's background unlinks the page from its theme colour 2026-10-24 10:07:58 +02:00
Maurice 64edf899ac fix(studio): stickers without gaps: the ramen bowl overlaps its broth, the croissant is one closed crescent, the nata's ribs stay in their cup 2026-10-24 10:07:58 +02:00
Maurice 0a9075af73 test(studio): the head band 2026-10-24 10:07:58 +02:00
Maurice c3a5383f5e feat(studio): the head band in the dialogs' shape, tools grouped by what they do, book view beside the zoom 2026-10-24 10:07:58 +02:00
Maurice e96813edcf fix(places): search_place says what locationBias does, so the assistant passes the neighbourhood instead of the city centre 2026-10-24 10:07:58 +02:00
Maurice 5e36e38b72 fix(offline): the vector prefetch takes the vector source and each source's own zooms; driving settings sync only with the add-on on 2026-10-24 10:07:58 +02:00
Maurice 890473ebd5 style(i18n): format the translated locale files 2026-10-24 09:41:34 +02:00
Maurice 2b86d1fda7 fix(i18n): translate the English left in the other 26 locales
About 2900 strings still carried the `// en-fallback` placeholder and some
3100 more were unmarked English copies, most of them from the Studio, the
assistant, OAuth and the date picker. Every one of them is translated now;
where the English word is the locale's own one (German "Status", "Backup"),
it is kept and marked `// same-as-en`. Placeholders, markup and product names
are unchanged.

"Studio", the photo book editor's name, joins Atlas and Vacay on the
untranslated check's list of names. The baseline drops from 2900 marked and
3100 identical strings to 1 and 158.
2026-10-24 09:41:33 +02:00
Maurice 1487ad97f1 test(trips): the removal names who removed 2026-10-24 08:58:56 +02:00
Maurice fc27b254bb style(i18n): format the German trip strings 2026-10-24 08:38:12 +02:00
Maurice 658536c217 fix(trips): a member another member removed cannot rejoin through the invite link
Removing a member deleted their membership row only, and the invite link
checked nothing but its own token, so whoever kept the link walked straight
back in (GHSA-wr4c-964p-39jg).

trip_member_removals now notes who another member took off a trip. The invite
link refuses them on preview and on accept with a 403 that says so, old link
or regenerated, and the join page shows that instead of the join button.
Adding them back by name, or through a trip-bound invite or a plugin acting
for a member manager, lifts the note. Leaving a trip yourself leaves none, so
the link keeps working for whoever left and for everybody else.
2026-10-24 08:29:51 +02:00
Maurice 00018959c6 fix(studio): a full page takes no new element, the opacity slider commits a move only, a theme draft survives saves
Every add in the Studio (a sticker, a shape, an icon, a text, a frame, a line,
a travel element, a photo from the content panel or a drop), a text style and
a grid of frames appended without looking at the page's cap of 90 elements,
which paste and duplicate already kept. The save then failed with a 400 and
every later save of the book failed the same way. Each of them now leaves a
full page as it is and selects nothing, as a paste does.

The opacity slider committed on every key let go on it, so the Tab that moved
the focus there wrote the value it was last moved to, an empty undo step at
best and another colour's opacity at worst. It commits once after it moved,
on the key or the pointer let go or when the focus leaves.

The Theme panel threw away colours, faces and motifs being tried whenever the
book came back from a save by another editor or the assistant, because the
same theme read anew is a new object. The draft now follows the book's theme
only when the theme itself changed.
2026-10-24 04:19:25 +00:00
Maurice 117af4a8a7 fix(studio): the sheet's keys leave a key to the control that took it, and the layers tree works by keyboard
The sheet listened on the window in the capture phase, before any control saw
a key. Delete on a focused gradient stop removed the whole shape, an arrow on
the angle dial or the radial centre also moved the shape, and Delete, the
arrows and Ctrl+V pressed on a button of the assistant's window acted on the
page although the window stops its keys. The sheet now listens on the
document as the key bubbles, after every control had it and still before the
page's Escape on the window, and leaves a key whose default was prevented. A
gradient stop takes Delete and Backspace even when the last two stops cannot
go.

The layers panel was a tree a keyboard could not walk: only its toggles could
be focused. The tree is now one stop in the Tab order, the row last focused or
else the selected one. Up and Down move along the rows, Home and End to the
ends, Right opens a group or steps into it and Left closes it or steps from a
member up to its group (mirrored right to left), Enter or Space selects a row
as a click does with Shift adding, and F2 renames it. Enter and Escape in the
name field hand the focus back to its row, and the row in focus is ringed. A
hidden or locked layer that cannot be clicked on the page is reached this way.
2026-10-24 04:06:37 +00:00
Maurice 84e18e4954 fix(studio): paste style, the page background, layouts and fill page keep the engine's rules
Paste style wrote the copied fields straight onto the element, although a
pasted look is meant to be the style operation the assistant sends. It now
runs the engine's own restyle (bookElementStyled): a colour or face pasted by
hand takes the element off the theme, so the next theme applied no longer
brings the old colour back; a plain fill replaces the paint of the shape it
lands on instead of staying hidden under it; and a line takes another line's
look without its points, which are its course, not its look.

A background picked by hand in the inspector kept the page's link to the
theme, so the next theme applied set the page back to the theme's colour. The
picker and the background operation now paint through one helper that takes
the link away (withSpreadPaintByHand). Giving one page's background to every
page carries that page's link along, or takes the others' away when it was
painted by hand.

A built-in layout, a book's own layout and laying a page out again from its
entry ignored locks in the Studio: a locked shape was dropped and a locked
photo poured into a slot unlocked, while the assistant's layout operation left
both where they were. All four now lay a page out around its locked elements
through one function (layoutAroundLocks), which the engine's layout operation
uses too.

Fill page and fill spread kept the photo in its group, so the background
picture was selected and dragged with its caption and listed under the group
at the front. It leaves its group as the engine's fill does, and a group left
with one member goes.
2026-10-24 04:06:27 +00:00
Maurice 090e754077 fix(studio): a restore brings in no photo from outside the journey's gallery, letter fills counted
restore_book_layout took any layout the caller sent, so a layout naming a
photo of another journey was kept, and laying a page out on it put that photo
into this book while place_photo refused the same id. The layout is now held
to the rule a page restore states: every photo it names, in a frame, on a map
marker or in the letters of a text, is in the gallery or on a page of the book
already, or the call is refused with photo_not_in_gallery.

restore_book_page and the design apply read the photos of a page in one place
now (book-photo-refs.ts), which counts the photo a text is filled with. A page
put back with letters filled with a photo from outside the gallery is refused
as a frame of it would be, and a designed page whose letter fill photo left
the gallery since is skipped as photo_gone; on a new book's cover the letters
keep their colour instead, as its frame is emptied.

A saved layout keeps no photo of the page it was taken from in its letters, as
it keeps none in its frames, so laying another page out on it no longer
carries that photo along. A layout kept before this fills such letters with
the first photo of the page it is laid on, or leaves them in their colour.

The style operation checks a letter fill photo only when the change sets the
fill, so a title whose photo left the gallery can still be resized or
recoloured, as the text operation already allowed.
2026-10-24 04:05:59 +00:00
Maurice 4322159d8e fix(i18n): the Studio rail and its tabs in every language, and the rail in the help
The names on the rail and in the tab rows the rebuild puts first (Pages,
Content, Layouts, Elements, Shapes, Text, Photos, Entries) were still English
placeholders in twenty one languages. They carry each language's own word now,
and so does the quotation mark, which the Text tab shows as a word under its
tile instead of only in a tooltip. The Frames tab's name was already translated
in those languages and loses the placeholder mark it still carried.
The help on Studio and the wiki describe the rail as it is: seven sections,
Theme and Layers set apart, the stickers under Elements, and the inspector's
order.
2026-10-24 03:08:27 +00:00
Maurice bdf5767a5f feat(studio): rebuild both side panels around one head, one row and one order
The rail lists its sections in the order a book is made: Pages, Content,
Layouts, Elements and Travel, then Theme and Layers set apart below a rule.
Stickers are a tab of Elements beside the icons instead of a rail section of
their own, and Elements has six tabs (text, shapes, lines, frames with the
grids, icons, stickers). The quotation mark moved to the text styles, the
outline rectangle to the basic shapes and the plain rule to the lines. The rail
and every tab row are tab lists the arrow keys walk through, and a long name
wraps instead of being cut. The icon and sticker searches say in the field how
many match, which the sticker library's own head used to.

Every panel on both sides opens on the same head, and every labelled control
uses one row that moves the control under its label when the two do not fit,
so no label sits in a fixed column any more. Number fields, chips, sliders,
tab labels, theme names and travel cards wrap the same way, a country beside
a stop wraps under itself, and an upload cell holds its words to two lines
with the whole label in its tooltip. The heads, shelves and groups are real
headings, so a screen reader can walk the panels by them.

The inspector shows every kind in one order: what the element is and says,
its position and size, its look, its effects, then Arrange. Align and the
stacking buttons are one Arrange group with a button that opens the page in
Layers; its rows sit under their labels, each in two sets that move to a
second line whole. Rarely used groups start folded and open on an element
that already uses them (a turned element, a map held at a zoom, a summary set
in another face), and every group mounts afresh for the next selection. A
sticker's duplicate angle and opacity are gone in favour of the shared
position group, which now has the turn buttons for every kind. With several
elements selected it keeps their angle, opacity and turn, and the mirror when
all of them are stickers, and leaves the frame's numbers to one element. A
photo's fill page and fill spread sit under its crop. With nothing selected
the inspector shows the page background, the book's theme with the way to the
Theme panel, and the page numbers.

Seventeen translation keys nothing has used since before the rebuild (the old
inspector title, the empty spread note, the summary and country list names,
the roads buttons and others) are gone from every language. The broad key
template the old panels used had counted them as reached; the format names,
the empty photo grid messages and the map source hints are now named where the
key check can read them.

Also fixed on the way: the travel stops list shared its class with the
gradient stops and was laid out as sixteen pixel handles; the alignment
buttons were named in raw English; the map's print resolution note joined its
two halves with a dash.
2026-10-24 03:08:27 +00:00
Maurice e8b071aecd fix(tours): restoring a recorded track and saving a copy wait for the tour's queued edits
Both go straight to the server. While an edit of the same tour still waited in
the queue, the restore went out first and its answer replaced the cached edit,
and the edit replayed afterwards planned the track over again. A copy copied
the server's older line instead of the one on screen. Both now wait the way the
actual track writes do: offline, behind a queued write of the tour, or for a
tour the server has not got yet they refuse with OnlineOnlyError, and the
planner says a connection is needed and the waiting changes go first, in all
27 languages.
2026-10-24 02:18:08 +00:00
Maurice f128346a54 fix(tours): the other ways of a leg follow the points of the call that asks for them
The search behind propose_hike_leg_way is kept for a while under the spots of
the hike, but it kept the finished points of the first call. A later call over
the same spots in another form, such as a round trip given with its way back as
its last point, or with other names or stage ends, got the first call's points
back, and its create_with no longer came back to the start. The search now keeps
only the pins that hold each way, and each call sets them on its own points.
2026-10-24 02:18:08 +00:00
Maurice 5bf6af7afc fix(tours): new points of a saved hike take each saved point once and keep its shaping mark
A new point within 25 m of a saved one took that point's key, name and place
link, even when another new point already stood on it. A stop put in just
before a stop a stage starts at took the stage's key, so the stage left its
day and the undo could not bring it back. The saved points are now matched one
to one, the nearest pairs first, so a point standing on a saved one keeps it.

The points a hike is changed through carry no shaping mark, so an inserted or
removed stop and a reshape turned every shaping point into a stop. A via point
standing on a saved shaping point now shapes the line still, unless a stage
ends there.
2026-10-24 02:18:08 +00:00
Maurice 1a941a6f1d fix(tours): a recorded track is neither turned round nor closed by update_tour
A GPX import keeps no points of its own, and the tour read makes up a start
and an end from its line. update_tour with reshape turned or closed those two
made up points, so the recorded line was replaced by a router line between its
ends. A reshape of a tour without points of its own is now refused with the
recorded track wording, before anything is planned or saved.
2026-10-24 02:18:07 +00:00
Maurice 508e17bc11 fix(assistant): the phone chat holds the focus as a dialog, and its lists close on Escape
The full screen chat is a modal dialog but left the focus on the trip behind
it. It now takes the focus when shown, keeps Tab inside and hands the focus
back when set aside or closed, through the helper MSheet now shares. The
dropdown panels take the focus onto their first entry, close on Escape and
give the focus back to their button; the history and menu buttons say
whether their panel is open, and the open chat is announced as current.
2026-10-24 02:06:24 +00:00
Maurice 4d00858b8a fix(assistant): the window's menu and trip list keep the keyboard in reach
The home chat's trip list opened before its trips had loaded, so the focus
stayed on its button and no trip could be reached by keyboard; the first trip
now takes the focus once the list is there. The header's menu says whether it
is open, and closed by Escape or a pick it hands the focus back to its
button, so the next Escape closes the window again. The open chat in the
history list is announced as the current one.
2026-10-24 02:06:24 +00:00
Maurice 7e5b23808d fix(assistant): the home chat leaves a journey's own writes to the journey
The home chat kept every write whose arguments named no trip and no journey,
so the journey group brought update_journey_entry, delete_journey_entry and
reorder_journey_entry_photos (they name only the entry) and the Dawarich
journal entry (it names the journey as journalId). The writes of the
journey's controllers now stay out by their controller, and journalId counts
as a journey, as the home chat's own contract and prompt say.
2026-10-24 01:41:36 +00:00
Maurice bfcc205f05 fix(assistant): a change left waiting past its day no longer holds the next one back
The open changes of a thread are now the ones still inside their decision
window. A call older than a day already reads as expired in the thread and
can no longer be decided, but it stayed approval_requested until the nightly
sweep, so "+ Tour", "+ Day" and "Plan this" answered decide_first with
nothing left to decide, and a decision had to name the expired call too.
listOpen takes the window's start, and every caller passes the same cutoff.
2026-10-24 01:41:36 +00:00
Maurice a6a9f3bbdd fix(deps): multer, undici, nodemailer and fast-uri past the advisories published overnight
multer 2.5.0 (GHSA-q7qp-rjg7-q26m), undici 7.30.1 and 8.12.3 (GHSA-4qv8-2p9j-2685,
GHSA-f72v-6w45-q472, GHSA-xjj6-f33q-929c), nodemailer 10.2.1 (GHSA-8c8m-p3m8-cjg8,
GHSA-m943-m63m-w62w, GHSA-85x2-q946-cp5h) and fast-uri 3.1.9 (GHSA-w74x-v6f9-j3xg and
three more). The server's own ranges and the root overrides hold the fixed versions as
floors, the nested undici of isomorphic-dompurify's jsdom included.
2026-10-24 01:40:57 +00:00
Maurice 5651c11803 feat(assistant): the book designer works from a brief, with archetype covers, lively spreads and stickers
- design_book takes a brief (energy, decoration, collages, theme), a cover
  archetype and a cover lockup, and pages.overrides that give single pages of
  the plan another beat or layout. Directives written before parse as they
  did, and a design asked for none of it is composed exactly as before.
- The engine composes the seven cover archetypes from the book's own photos,
  words, theme faces and colours, lockups, shapes and stickers, varied by the
  seed: spine band, poster pop, cut-out, illustrated postcard, grid and
  badge, minimal serif and stamp collage. A title the directives give a
  display tier is held to it, and the notes say when a band, a badge or a
  stamp holds it smaller or a cover leaves a layout out.
- A lively or loud book turns two in five hero and grid spreads into
  collages, script overlays, badge grids, stamp rows or two-tone splits, the
  first of them always, turns and frames the prints of its other pages, sets
  its cover title larger and takes one or two, or three to six, stickers of
  its theme's motifs a spread, never over words, a photo's detail or the
  fold. A calm book asked for collages gets them and nothing else.
- Every turn the engine gives a print or a sticker stays within what the
  brief's energy takes, a calm book's within its theme's own, so a design is
  never judged against its own brief.
- New checks name a flat cover, a page without a lead, colours or faces off
  the theme and, for a design made with a brief, a book livelier or calmer
  than it asked, on the pages the design made and on a page revised in the
  draft. Page edits answer with the art checks too, and the assistant's
  cards name them in every language.
- list_book_assets lists the shapes, faces, lockups, themes and patterns
  beside the stickers, and the Studio's shape groups and photo frames are one
  list the assistant reads too.
- The Studio prompt gains an index of themes, archetypes and faces and JSON
  recipes of the elements to add and style for each cover and three lively
  spreads, and a turn that designs the book may preview sixteen pages, still
  three images a request; a viewer is told of none of it.
2026-10-24 01:14:03 +00:00
Maurice ff749611c6 feat(studio): the Templates panel offers the decorated templates with a style filter
The Templates section of the rail now opens on the decorated templates of
the page's kind, covers two to a row and spreads at full width, each card
the page renderer drawing the page the click will lay: this page's photos
and words in it, in the book theme, a cover in the journey's title and
subtitle as the assistant's layout sets it. A row of style chips narrows
the set to one archetype. A click is one undo step on the page as it is
then, refused for the other kind of page, and opens the photos when frames
are left empty. The book's own layouts and the built-in wireframes follow
below, and the help guide and the wiki describe the panel as it now is.

The panel moved out of StudioSidebar into its own file; the sidebar keeps the
rail, and its two physical sides went with the code, so the rtl baseline
entry goes too. The Travel panel and a template's map build the journey
they draw with one helper.
2026-10-24 00:36:00 +00:00
Maurice 49f7a447d3 feat(studio): decorated templates, 21 covers and 16 spreads laid on any page in the book theme
A decorated template is a whole designed page kept as data: full document
elements in fractions of the page they were drawn on, with roles for the
photos and words a page brings, links to the theme for its colours and
faces, groups for its lockups (a title with its date line, a badge with its
words, a stamp with its postmark) and an archetype with tags. Seven cover
archetypes in three variants each and sixteen double spreads ship in
shared/src/book/templates/data.

Laying a page out on one pours its photos into the photo roles in order and
its words into the title, subtitle, date, story and caption roles, takes
the words it has none for from the book or the journal entry, sets the
template in the book theme, scales it to the page format with its bleeds
on the bleed of that page, and parks what has no room, a date line the date
mark was not made of included. A text set on a colour is made to read there
at the size it was fitted to, on a band of ink or paper when neither reads.
A mark set as text, such as a large quotation mark, is design to every
layout and never taken for the words of a page.

A fitted text keeps the size it was drawn at as its ceiling, so a shorter
title grows back, and every fitted text is now solved with eight percent of
headroom on letter width: set in the browser, the bold condensed faces and
several scripts run that much wider than the letter measure, and a fit at
exactly the measure wrapped its last word out of the frame.

The layout operation of edit_book_page and revise_book_design takes the new
ids as a checked string and keeps the name of a locked group,
list_book_layouts lists them with archetype, tags and roles, and the design
prompt names them. A sibling extract script reads a page drawn in the
Studio back into the format, roles, tints and groups kept.
2026-10-24 00:36:00 +00:00
Maurice ea5447056b feat(studio): the seven cover archetypes a decorated book starts from 2026-10-24 00:35:48 +00:00
Maurice baec23cf73 fix(deps): body-parser past the compressed body advisory
body-parser before 1.20.9 and 2.x before 2.4.0 can be held up by trailing
bytes after a compressed request body (GHSA-27r3-jxmh-p828). Both majors are
held as floors in the root overrides, express 5 under Nest and the MCP SDK on
2.4.0 and express 4 on 1.20.9, with qs on 6.16.1 as 2.4.0 asks.
2026-10-23 22:41:20 +00:00
Maurice a202f2dc95 fix: the four reliability findings Sonar reports on dev
- The warmed fonts' key sorts by code units, so the same faces give the same
  key in every locale.
- The nearest weight of a snapshot face starts from the first face it has.
- The round hike search names its candidates with their place in the list
  explicitly instead of handing map a function of two arguments.
2026-10-23 22:40:27 +00:00
Maurice a2951f2c99 test(studio): the page in words names a face with its role, as describe_book_page writes it 2026-10-23 22:26:58 +00:00
Maurice 6395ef655f style(studio): the photo view and the findings in Prettier's form 2026-10-23 22:08:13 +00:00
Maurice cbf9325e7e fix(studio): the book tools state every field once, and the server typechecks within its memory again
- The element, spread, document, page operation and card unions carry a
  name of their own, so the declarations of every schema that holds them
  refer to them instead of spelling them out again. The shared declarations
  shrink from 9 to 5.5 MB and the server's typecheck from 4.4 to 2.9 GB, so
  the build fits the image's 4 GB again.
- edit_book_page and revise_book_design take add by what the element is,
  with an id of its own; its look follows with style by that id in the same
  call, every look field described once. A change an assistant card kept
  with its look on add still reads and runs.
- restore_book_page states what every element has once, then each kind's
  own fields and the type the five travel kinds share, without defaults; the
  page is read as a spread again before it goes back.
- The theme links are explained once in the page edit's description, and
  style points a text's face, a shape's paint and outline and a photo's crop
  look at their one full description.
- The lockup roles live in a module of their own, so the operation schemas
  no longer import the lockup engine, whose cycle left the operations unset
  in the shared tests.
- The theme switches no longer fall through a comment, the newer document
  test follows the current revision, the line inspector names its stroke
  keys, a text's anchor reads no physical side, two shapes record the
  proportions they are drawn at, and the photo look and line tests follow
  the slider and the folded ornaments.
2026-10-23 22:08:13 +00:00
Maurice a32465ad32 fix(studio): a line keeps its own colour when a theme is applied, adopted or described 2026-10-23 22:08:13 +00:00
Maurice 01abb90797 style(i18n): the locale indexes in Prettier's order 2026-10-23 22:08:13 +00:00
Maurice bce74d6c56 fix(studio): one import of each locale file, and a photo's cut edge in the split out photo view 2026-10-23 22:08:13 +00:00
Maurice 75d0593198 feat(studio): text effects, fit to frame and 24 lockups in a Text styles shelf
- A text can be cased (capitals, lower case, small caps), placed at the top,
  middle or foot of its frame, outlined, given a hard shadow or a marker band,
  bent on an arc or round a circle, filled with a gallery photo or left
  hollow, and shrunk to fit its frame. Every effect prints as vector: copies
  and strokes for HTML type, textPath and a clip over an <image> for SVG.
- Fitting is solved when the text is edited, in the Studio and in the edit
  engine, and stored; the chosen size is kept as the ceiling.
- Text sizes reach 400 pt and tracking 1.5 em.
- The Elements panel opens on a Text tab: the plain styles and 24 lockups
  (titles, badges and stamps, chapter marks, quotes, captions, places), each
  drawn with the book's own title and placed as one group.
- The assistant sets every effect through style and add, places a lockup with
  add_lockup, reads the effects in describe_book_page and gets two new
  findings (curve_overflow, effect_heavy) beside photo-fill and band checks.
- Book format revision 6.
2026-10-23 22:08:13 +00:00
Maurice 82b8eb1067 feat(studio): book themes with palette, font pairing and motif set 2026-10-23 22:08:13 +00:00
Maurice ce9c94a77b feat(studio): a second shelf of shapes, cut edges and a drawn line with heads and ornaments
- Fifty more shapes: arches, paper cut-outs (leaves, coral, weed, organic
  circles, a wonky star and sun), stamps, a postmark, a rosette, tags and
  ticket stubs, sunbursts, ribbons and bands, speech bubbles, drawn arrows,
  hearts, stars, photo frames and ornaments (scroll, laurel, fleuron, corner).
  The broad ones are photo frames too, and each tile is drawn at its shape's
  own proportions, which a new shape is also placed at.
- A cut edge on rectangles, circles, arches and the other broad shapes, painted
  or not, and on a photo's cut: zigzag, scallop, a postage stamp's perforation,
  wavy, torn and deckle, with a depth in millimetres. Cut in millimetres, so
  holes stay round on a wide frame.
- A line element: two to 32 points dragged on the canvas (a handle pulled from
  the middle adds one, a double-click removes it), straight or curved, solid,
  dashed or dotted, a wave or a zigzag, a hand-drawn wobble, a head on either
  end and an ornament in the middle. A shelf of lines and dividers places them.
- The assistant reaches all of it: add and style take the line (its points on
  the spread in mm) and the edges, describe_book_page says them, and two new
  findings say when a line is too thin to print and when an edge is set on a
  shape that takes none. A photo's mask is checked against the shape set
  rather than listed again, which keeps the page edit's schema smaller.
- BOOK_SCHEMA_REV goes to 6.
2026-10-23 22:08:13 +00:00
Maurice 7a7876b9f4 feat(studio): photo look with border, hard shadow, zoom, mirror, adjustments, duotone and cutout
Photos get a look beyond crop, mask and frame style, in Studio and for the
assistant alike:

- border (width and colour, following the mask or the corner radius), a hard
  offset shadow and a die-cut cutout edge, all flat vector shapes in print
- zoom 1 to 4 about the focal point and a left to right mirror
- brightness, contrast, saturation and warmth, and a duotone; these are
  filters, labelled raster in print in the inspector
- the print resolution of the selected photo in the inspector, with a warning
  under 200 dpi, measured from the original

PhotoView moves out of SpreadView into its own file and uses per instance
SVG ids, so a spread drawn twice keeps its clips and filters apart. The style
operation takes the look, describe-spread lists it, the dpi and crop findings
account for zoom and mirror, and a new raster_effect finding notes filters on
a large photo. BOOK_SCHEMA_REV goes to 6.
2026-10-23 22:08:13 +00:00
Maurice 9b8626622f fix(deps): zod at 4.6.7 across the tree, jimp's copy included
Zod before 4.6.6 can exhaust memory while it validates an oversized
constrained collection (GHSA-mrhx-c7xc-m3w5), and every request body and
tool call goes through it. The workspaces ask for 4.6.7 and the root holds it
as a floor for the whole tree, so jimp, which pins zod 3 and has no release
on 4, resolves to the same copy. Its option schemas use only object, number,
tuple, literal, union, nativeEnum, boolean, string, instanceof and array,
all of which zod 4 keeps; resize, scaleToFit, crop, cover, contain, blit,
composite, rotate, flip, quantize and the jpeg and png encoders run on it.
2026-10-23 22:06:45 +00:00
Maurice dad2a3d523 fix(places): the feature pairs and the inspector test follow the removed avatar upload and the picture repo 2026-10-23 23:08:07 +02:00
Maurice b3180b898e fix(studio): "+ Page" beside Import, and the elements tabs as wide as their words
The add button reads "+ Page" with the full "Add page" as its title and
accessible name, so it no longer wraps onto two lines. The elements panel's
tabs size to their word and break onto a second row when they do not fit,
where equal widths cut the longest word.
2026-10-23 22:45:27 +02:00
Maurice 81047917a1 fix(studio): an inspector label as long as "Ausrichtung" no longer runs into its control 2026-10-23 22:44:13 +02:00
Maurice 31dba65906 fix: the last red checks after the tour, home and place picture merges
A GPX file that mixes routes and tracks gives each its own text: the pieces
are matched to the reader's rows by kind, routes first as the reader gives
them, where they were paired by index and a route got a track's points. The
place pictures reach the network through a small repo instead of the api
module, the wanderer parity case and the tool catalog test allow wanderer,
and the knip and theme baselines follow the lower counts.
2026-10-23 22:40:14 +02:00
Maurice 770fb2a2b3 chore(client): the size baseline follows the shorter route calculator and place form 2026-10-23 22:23:32 +02:00
Maurice 8c7fc989dc fix: the merged tour, home and lightbox changes pass their checks
The demo gate test allows wanderer as the parity harness does, the open tour
store is listed in the store registry, the trip chat card's test reads the
dates and not the question's hidden label, the lightbox leaves the rtl
baseline, and the tours module is formatted.
2026-10-23 22:19:23 +02:00
Maurice 58204e72bd fix(ai): a ChatGPT turn that searched the web goes on after a tool call
The output items of a step were replayed as they came, web search calls
included. With store false the provider cannot resolve such a call again and
refused the next step with HTTP 400, which ended the turn with an error right
after a hike was proposed. The search calls are left out of the replay; what
they found is in the answer's text and its sources.
2026-10-23 22:16:22 +02:00
Maurice 53cefba3f3 fix(studio): the reindeer's antlers sit on its head, every sticker on a tile of one size, no theme chips, and the weight on Studio's own slider 2026-10-23 22:09:18 +02:00
Maurice 46613ada23 style(studio): format the merged findings and element ops 2026-10-23 22:06:29 +02:00
Maurice 5d334d7ec8 feat(assistant): the assistant opens outside a trip, as a home chat about all trips
A third scope, home, next to trip and journey: threads that name neither a
trip nor a journey (the CHECK now only forbids both), reached under
/api/assistant by their owner only. A home turn reads the user's trips
through list_trips into a short card (under way, next, later, behind,
undated), offers every read (a trip's on the id the model names, checked by
the tool with the user's rights) and only the writes that take no trip or
journey (atlas, vacation planner, collections, tour library, settings), all
held as change cards. open_trip_chat answers with a card that goes on in
that trip's own chat with the question waiting in its composer.

On the desktop the window opens on the home chat outside a trip; its head
lists the trips to switch into and a switched trip chat has the way back.
On the phone the home chat opens full screen from the dashboard head and the
dock's More list, and the card opens the trip with its chat. Both run on
useAssistantHome.
2026-10-23 22:00:06 +02:00
Maurice cd60d4d859 feat(tours): assistant takes found places into saved tours and route files from the chat
P6: insert_tour_stop and remove_tour_stop take a place into a saved hike or
city walk as one stop and out again, each the other's undo, with change
cards in the chat. The planner's insertion rule (poiInsertion) moved to
shared so the planner and the server place a stop the same way. "+ Tour" on
a found place holds it as a stop of the saved tour open in the planner.

P7: GPX, KML and KMZ files attached in the chat are read into their tracks
and each is held as a change that takes it in as a hike through
import_tour_gpx, undoable per track; the GPX text stays on the held call.
2026-10-23 22:00:05 +02:00
Maurice 454fc1a5f4 feat(tours): round hikes, other ways of a leg and wanderer trails through the assistant
The assistant can now search a round hike of about a wished length from one
point and the router's other ways for one leg of a hike, with the planner's
own search, and propose each one as a hike card to compare and take. It can
also search the user's wanderer trails, preview one as a card, import it and
check a wanderer tour against its source.

The round search, its loop geometry, the test whether two lines are one way,
the other ways among a router's offers and the pins that hold a chosen way
move to @trek/shared; the planner and the road trip use them from there.
2026-10-23 21:59:43 +02:00
Maurice 767d91146a feat(tours): the assistant edits stations, reverses and closes tours, sets breaks, colour and copies, and writes ski and paddle plans
City walk changes take edit, reverse and close_loop; edits alone keep the
saved way and never ask the router. update_tour turns a hike round or closes
its loop with reshape. New MCP tools mirror the Tours tab's REST writes and
reads: update_tour_details, set_tour_route_color, copy_tour,
classify_ski_track, set_paddle_plan, decide_paddle_obstacles,
restore_recorded_track, get_tour_trail_details and find_horse_routes, with
change cards, undo where the answer keeps the values before, and broadcast
parity cases.
2026-10-23 21:59:02 +02:00
Maurice c6b3f609b7 fix: dev green after the Studio, place picture and tour merges
The ids of long catalogues (fonts, shapes, patterns) read as a checked string
in the book tools' schemas, so restore_book_page and the page edit stay under
their ceilings. The lightbox takes its reading direction from the language.
Baselines follow the removed avatar upload and the lower counts, the doubled
NEWER constants and three unused imports go, the locale indexes are sorted,
and the place gallery, picture, sheet, inspector weight and paddle tests
follow the merged behaviour.
2026-10-23 21:58:43 +02:00
Maurice 7b50b12fe6 fix(studio): a sticker's colours join the book's palette 2026-10-23 21:24:09 +02:00
Maurice d8f7143b4c fix(studio): a slider commits the value it was moved to, and the paint render test uses a real shape
- The opacity and paint sliders remember the value they were last moved to and commit that when let go, instead of reading the controlled input, which still shows the old value until the parent renders.
- The render test's old-fade shape is star-5; star is not a shape, so the whole fixture failed to parse.
2026-10-23 21:07:31 +02:00
Maurice d206435af1 test(studio): a Studio save carries its revision, and the layers foot is drawn with its selection 2026-10-23 21:07:29 +02:00
Maurice d8213bfa42 feat(studio): groups, a layers panel, marquee, nudge, multi-resize, align and paste style
Elements can be grouped on a page and picked, moved, resized, aligned and
restacked as one. A layers panel lists the page front to back with lock,
visibility, rename and drag to restack. The sheet gains marquee selection,
arrow-key nudge, one box that resizes a multi-selection with Shift holding
the proportions, and the design-tool shortcuts. The inspector lines a
selection up and spaces it out, and copies and pastes a style.

The assistant gets the same through the group, ungroup, name and hide
operations, and describe_book_page lists the groups. The format gains the
optional group, name and hidden fields and a spread's group names, so
BOOK_SCHEMA_REV goes to 2.
2026-10-23 21:07:13 +02:00
Maurice 8c0707f34c fix(i18n): the sticker entry of the dynamic key list goes at its end 2026-10-23 21:06:10 +02:00
Maurice 8c6f5deab2 feat(studio): a sticker library of 148 hand-drawn illustrations, placed, recoloured and searched by people and the assistant
A new element kind, sticker, draws layered vector illustrations from a library in twelve themes, behind its own entry point @trek/shared/stickers. Studio gets a Stickers rail section (themes, localized search, recently used, click or drag to place) and an inspector for colour slots, mirroring, turning and opacity. The assistant gets list_book_assets, add and style of stickers in edit_book_page, stickers in describe_book_page and an overlap warning for a sticker over words. BOOK_SCHEMA_REV goes to 2.
2026-10-23 21:06:09 +02:00
Maurice 23c446b7e9 feat(studio): paint shapes and pages with gradients, alpha and twelve patterns
- A shape and a page take a paint: one colour with its own alpha, a linear or
  radial gradient of two to six stops, or one of twelve patterns (checks,
  gingham, stripes, pinstripes, dots, polka dots, zigzag, waves, grid, scales,
  sunburst, confetti). fill, gradient and background keep the colour that
  stands for the paint, so an older reader still draws something close, and a
  plain colour or the old cover fade is stored in the old fields alone.
- Gradients render as SVG in user space with ids per mounted copy, so the two
  halves of a spread in page mode never share a defs id; patterns are drawn
  out as geometry clipped to the outline and capped at 2000 primitives.
- The new paint picker has none, solid, gradient and pattern tabs, a stop bar
  to click, drag and pull stops off, an angle dial, a radial centre pad and
  pattern tiles drawn by the page's own generator.
- Every swatch row gains the book's own colours, colours read from the
  page's photos, recent colours and, where it applies, an opacity slider.
- With nothing selected the inspector sets the page background, gives it to
  every page of its kind, and lays a photo under the whole page.
- The assistant's background operation takes a paint as well as a colour,
  style and add set a shape's paint, and describe_book_page reads both.
- BOOK_SCHEMA_REV goes to 2.
2026-10-23 21:05:10 +02:00
Maurice 8ec560884e test(studio): drive the travel inspector's type through the typeface list and the weight slider 2026-10-23 21:01:30 +02:00
Maurice 269fce0331 fix(studio): declare Big Shoulders Display's bare style imports, and let a raced Studio save carry its revision 2026-10-23 21:01:30 +02:00
Maurice 72661196ee fix(studio): import Big Shoulders Display by its export name, which takes no .css 2026-10-23 21:01:30 +02:00
Maurice fabefed39f feat(studio): 34 typefaces with real weights and italics, a categorised font picker
The book catalogue grows from 7 families to 34, all self-hosted through
@fontsource under the SIL OFL 1.1 or the Apache License 2.0: condensed and
poster faces, heavy retro display, elegant display serifs, Montserrat, twelve
scripts and DM Mono. Weights run from 100 to 900, each family offering only
the weights it ships, and the serifs gain real italics.

The catalogue lives in @trek/shared (book-fonts.data.ts) with each family's
roles (display, heading, body, script, mono), weights, italics and letter
width, so the Studio, the findings, the design engine and the assistant read
one list. Page operations and the design engine set a weight a family lacks
at its nearest one, describe_book_page names the role and the weight, and
font_mix no longer counts scripts and mono faces, warning above three.

The Studio gets StudioFontPicker: the current family set in itself, a
searchable list grouped by kind with every family in its own face and the
book's own families first, and a weight slider over the weights the family
has. Faces load only when used; the assistant's page snapshot embeds only the
faces a page draws, the service worker installs only the latin files of the
original seven families, and the faces of an opened book are cached for
offline use. BOOK_SCHEMA_REV goes to 2.
2026-10-23 21:01:29 +02:00
Maurice 507d4f73bc fix(assistant): the tour package keeps to two prompt lines
The registry refuses a package with more than two prompt lines at load, which
failed every suite that boots the assistant. The planning line now holds the
tools, the profiles and the hike rules.
2026-10-23 21:01:14 +02:00
Maurice 6e43490ba0 feat(assistant): plan and save every tour type, bike tours, runs, paddle and ski tours included
The assistant can now plan and save every type the Tours tab plans. A bike
tour or a run is proposed and created through the hike's own plan and save
(propose_tour, create_tour), routed on the server with the costing of its
profile, now kept once in shared and read by the planner as well. A paddle
or ski tour planned with plan_paddle_route or plan_ski_route is saved with
create_paddle_tour or create_ski_tour, and "Plan this" takes those plans,
bike tours and runs too. Every create has its card and its way back.
2026-10-23 21:01:13 +02:00
Maurice 79fb71bc92 fix(places): the gallery's photo type stays inside its hook 2026-10-23 21:01:13 +02:00
Maurice beb2c0c258 style(i18n): format the French and Dutch place strings 2026-10-23 21:01:12 +02:00
Maurice bebfeeff97 test(places): the picture chosen in the editor, the gallery and the lightbox
Tests for the picker, the shared image field, staging and saving the picture,
the gallery hook and trigger, the lightbox's dialog, focus and reading
direction, the credit and the enrichment cache; the inspector, phone sheet,
details column, form and wiring tests follow the picture into the editor.
The picker's upload tile and credit line are components of their own, the
heading goes with an empty picker, the gallery's state names its place, and
'No picture' only shows when a picture is being removed.
2026-10-23 21:01:12 +02:00
Maurice 35566690e9 feat(places): the picture of a place is chosen in the editor, and the view opens its photos
- The editor's 'Pick a picture' is the one place a place's picture is set, on
  the desktop dialog and the phone sheet alike: upload one from the device,
  keep the current one (now shown as chosen), take an image attached to the
  place, or one of the sources' photos, or remove it. Nothing is sent before
  the save: the upload and the attached file are put in place once the place
  is saved, so Cancel leaves the picture as it was and a new place can get
  one too. Without the details column the same picker sits in the form.
- The view (inspector and phone sheet) shows the picture plainly; a click opens
  the gallery of its own picture and its sources' photos.
- The gallery drops an answer that arrives after the place changed, and its
  credits link to their source pages. The lightbox is a labelled modal dialog
  that takes and gives back focus, names its arrows and follows the reading
  direction.
- The picker leaves the details column (PlaceImagePicker), the column reads
  the shared enrichment cache, and PlaceAvatarUpload and usePlaceImagePick,
  which only the view used, are gone.
2026-10-23 21:01:11 +02:00
gso 357bcb72e3 feat(places): a photo gallery of a place, from its own picture and the pictures its sources know
The enrichment cache and the photo credit move out of the details column so
the view mode can reuse them, and usePlaceGallery gathers the custom image
and the provider candidates for a lightbox.
2026-10-23 21:01:11 +02:00
Maurice 47e04b506f fix(place-import): narrow the list id before building Google's list URL, and reach the data URL check through the service
The strict ratchet refused the list id that may be null in the URL, and the
places controller reached into place-import's provider directly.
2026-10-23 21:01:10 +02:00
Luffy 9373667084 feat: support importing Google lists from a configured data URL base 2026-10-23 21:01:00 +02:00
Maurice 87dc70a4c0 chore(deps): bump follow-redirects to 1.16.1 and vite to 8.3.5 in plugin-sdk
Replaces Dependabot's #2708, which was cut from main and carried its
version bump and a regenerated lockfile, and #2696.
2026-10-23 21:00:03 +02:00
Maurice 5b6de2bb16 feat(assistant): the groups of a packing or to-do card are sections with a band in their colour
A group's head is a full width band, its category colour faint over the
card's grey, with the dot, the name and at its end the count as a white pill
and the people; the count shows on a change card too.
2026-10-23 20:28:55 +02:00
Maurice 338548bf7f test(studio): the assistant panel waits for its lazy chat as long as a busy runner needs
The chat is a lazy chunk that grew with the kept notes and the place picker;
findBy's default second ran out on the CI shard, so the welcome was never
found. The waits for it get ten seconds, under the fifteen second test limit.
2026-10-23 20:10:07 +02:00
Maurice a94464a953 fix(assistant): citedSources stays inside the Responses client 2026-10-23 19:13:51 +02:00
Maurice 88e6f332d3 fix(assistant): CI of the web search and tour write cards 2026-10-23 19:13:51 +02:00
Maurice d07b8ba56e feat(assistant): web search through the provider, and cards with a way back for the smaller tour writes
- Web search: ChatGPT (Responses web_search), Anthropic (web_search server
  tool) and OpenRouter (web plugin) search the web themselves when the admin
  switch allows it (on as it ships). A search shows as a step ("Searching the
  web for ..."), the cited pages as source chips under the answer. Anthropic
  answers that searched are replayed with their blocks (new provider state
  adapter); the search step never goes back to the model as a call. The
  prompt says when to search and that chosen places still go through
  search_place.
- Tour writes: assign_tour_track (undo: take that assignment off),
  unassign_tour_track, set_tour_activity_excluded (undo: the other switch),
  link_tour_packing and unlink_tour_packing (undo from the link before, which
  both answers now name) get change cards and their ways back.
2026-10-23 19:13:51 +02:00
Maurice e9f7d5e77b test(i18n): the plural test loads its locale itself instead of queueing behind every other
FE-COMP-I18NCTX-011a starts all 27 locale chunks at once and does not wait
for them, so the Russian chunk of FE-COMP-I18NCTX-018 queued behind the rest
and the test failed in the whole file (and on CI), though it passed alone.
2026-10-23 19:13:33 +02:00
Maurice 9a7c520106 test(i18n): a locale's first load gets 12 s on a busy CI shard 2026-10-23 18:21:54 +02:00
Maurice 5ccd4674ac fix(assistant): CI of the found places, kept notes and walk visits; a tour's answer shows no found places 2026-10-23 18:21:54 +02:00
Maurice d1b8cb20a3 feat(assistant): found places open the place form, notes kept on its own, city walk stops checked off
- Found places: the strip shows only when the answer names one of them, the
  named ones first. A click on a card, or "Add to trip", opens the planner's
  place form filled in with the place on the day in focus, its Google details
  (photos, hours, description) loading beside it; "+ Day" and the day grid
  are gone.
- Trip notes: remember_for_trip runs at once without a card. The assistant
  keeps lasting facts the user mentions (who travels, needs, likes, budget,
  pace) on its own and offers the unsure ones as a follow-up; the chat shows
  "Noted for this trip: ..." with the notes a tap away, and reads them again
  so the dialog never saves over the new note.
- City walks: set_city_walk_visit checks a station off on its day (visited,
  skipped, or the mark taken back), with day_edit as in the day plan, a
  change card naming the station and its walk, and an undo that restores the
  status before while the station is as the change left it.
- Card heads are a step smaller (32px tile, smaller title and count), and the
  dot beside "Working on it" in the header is gone.
2026-10-23 18:21:54 +02:00
Maurice dd2431db97 fix(tours): a hike over several days reaches the router without its stage ends
The router checks its request against the strict plan point, which has no
stage_end, so every hike with a stage end was refused with "Unrecognized
key: stage_end" before a way was planned. The points go to the router with
their position, name and kind only.
2026-10-23 17:13:08 +02:00
Maurice 746f53ff7e feat(assistant): the assistant plans hikes over several days in stages
- propose_hike, create_hike and update_tour take stage_end on the stops a day
  ends at, checked by the tours' own stage rules (never at the start or the
  end, at most 60 stages) on REST and MCP alike.
- A proposal over several days names its stage ends and sets no single day's
  schedule; its card counts the stages and marks the stops they end at.
- New points for a hike in stages keep the keys of the stops they stand on,
  so a stage keeps its day; they are refused only where they would drop every
  stage end.
- The tours group and the prompt name the stages, how to put each on its day,
  and what to offer once a tour is planned: places along it, its packing, the
  GPX file.
2026-10-23 16:47:05 +02:00
Maurice 7f9a4da52a fix(assistant): a tour in a change row runs the card's width with room above it, and the latest start is short
- The tour's sketch, figures and profile in a change row take the whole width
  of the card and sit apart from the row above (wideBody), instead of under
  its text pressed against it.
- "Start by 16:08" says the latest start in three words; the reason (to be
  back before sunset) is its tooltip.
2026-10-23 16:19:07 +02:00
Maurice 8043b7952d fix(assistant): the journal's strip of tiles says how many entries it holds under it again 2026-10-23 16:10:10 +02:00
Maurice e198392060 fix(assistant): no leftover from the journal's old foot, and the timeline test reads the walks where they are said 2026-10-23 15:57:48 +02:00
Maurice deea669e28 fix(assistant): the journal card's band says how many entries it holds again 2026-10-23 15:54:03 +02:00
Maurice d6b624e400 fix(assistant): one tap plans the tour for real, and a clear request gets a proposal
- The server joins a take to the answer that drew its proposal; the card only
  looked in later answers, so it never found the take to decide and "Plan
  this" still waited below for a second confirmation.
- An answer that asks the user something offers no fixed chips at all, only
  the model's own answers to its question.
- The model proposes a clear request at once, saying what it assumed, and
  puts the way it recommends first, which the card shows chosen.
2026-10-23 15:44:39 +02:00
Maurice 263d650460 chore(planner): keep TripPlannerPage at its size, the Tours map's new prop on an existing line 2026-10-23 15:38:31 +02:00
Maurice a3fc696510 feat(assistant): the chat's cards wear the trip dialogs' look
The cards were rows of badges in badges with no head of their own; they now
follow the frame #2541 gave the trip's dialogs and booking cards.
- Every answer card and the change card open on a tinted head band: a raised
  tile with the icon (a route its sketch), the kind as an eyebrow, the title,
  one quiet line and the count; the change card turns green once decided.
- Figures are framed fields under their labels (a route's distance, climb,
  descent, time), the parts of a card sit under eyebrows (height profile,
  stops), and the ways onward sit in a foot bar across the card.
- A route's stops are a timeline down a line, each walk on the line between
  two stops; the ways of a route are calm choices with their figures in one
  line and how they differ from the chosen one in another.
- Packing, to-dos, the journal, Vacay and the Atlas get the band; quantities
  and weights are quiet figures, an expense's place and split share one line.
2026-10-23 15:33:08 +02:00
Maurice 9f6f62c6ae feat(assistant): a proposed route shows on the Tours tab's map too, and its ways leave the map once one is planned
- The Tours tab's map draws the routes the chat's cards show and follows the
  assistant's camera, as the plan's map does; "On map" there stays on it and
  only a tab without a map switches to the plan's.
- Once one of an answer's ways is taken into the trip, the answer draws none of
  them: the taken one stands as its change, a discarded take brings them back.
2026-10-23 15:20:44 +02:00
Maurice a70ff750a6 test(studio): the typeface and display checks assert what they are about
BOOKQA-009 looks at font_mix alone (three one-text pages also repeat their
layout), and BOOKSTY-004 measures the display tier against the title the
classic style already sets (34 pt on this fixture, as on dev) instead of a
guessed size.
2026-10-23 14:20:24 +02:00
Maurice 815a523d1c feat(assistant): Studio's assistant may design lively books, not only restrained ones
- The design prompt keeps the print rules and adds energy: calm keeps today's
  rules, lively and loud layer photos, tilt, set oversize display type and
  block colour. Type sizes come as numbers relative to the page, and the model
  answers eight silent questions and previews the cover twice before it
  proposes.
- The type scale reaches 2.5 and a display tier sets a cover title up to
  200 pt; existing books keep their sizes.
- An overlap is severe only where more than half of a photo or a text is
  covered by accident; a title over a photo, a framed inset or a turned photo
  is layering.
- The assistant may use every icon of the icon tab, checked by name, and every
  enum of the book tools says what its values mean.
2026-10-23 14:15:01 +02:00
Maurice 30382dc366 feat(studio): a book saved by a newer Studio opens read-only instead of losing what it cannot read
- A book carries schemaRev. The reader that meets a newer one opens the book
  read-only with a reload banner and saves nothing, where it used to drop the
  elements it could not read on the next save.
- The server refuses a save, a restore or a tool edit that would write an older
  revision over a newer book or one newer than itself (409 book_schema_newer),
  on REST and through every book tool alike.
- normalizeBookDocument moves to book-normalize.ts.
- Print waits for photos eagerly and up to 15 s for an export of more than 40
  photos, and skips images without a source.
2026-10-23 14:15:01 +02:00
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
3406 changed files with 325426 additions and 23263 deletions
+51
View File
@@ -53,3 +53,54 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
## Typefaces: Studio books and the app
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
copyright notice of the family's authors in its `LICENSE` file.
Both licences allow a font to be bundled with software and embedded in a
document made with it, so a printed or exported book may carry the faces it is
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
https://www.apache.org/licenses/LICENSE-2.0
| Family | Package | Licence |
|---|---|---|
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
+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"
+3
View File
@@ -112,6 +112,9 @@ data:
{{- if .Values.env.TREK_PLACES_URL }}
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
{{- end }}
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
{{- end }}
{{- if .Values.env.AMAP_API_BASE }}
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
{{- end }}
+1
View File
@@ -108,6 +108,7 @@ env:
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
# TREK_PLACES_URL: ""
# GOOGLE_LIST_DATA_BASE: ""
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
# Defaults to https://places.liketrek.com.
+67
View File
@@ -0,0 +1,67 @@
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
/**
* Which font files the service worker installs with the app.
*
* The precache takes every woff and woff2 the build emits, and with the book's
* catalogue at thirty four families that is every weight, both styles and
* every script subset of each: megabytes fetched at install by every user,
* most of it for faces no book of theirs is set in. So the book's faces are
* held to this:
*
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
* imported in src/main.tsx) stay exactly as they were.
* - The faces the book shipped with before the catalogue grew
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
* still opens offline before Studio was ever opened online.
* - Every other file of a book family, woff, other subsets, new families,
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
* which the Studio fills with the faces of each book it opens
* (src/components/Studio/bookFontWarm.ts).
*
* Shared by vite.config.js and its test, which reads the filter against file
* names as the build writes them.
*/
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
export const APP_FACES = {
poppins: [300, 400, 500, 600, 700],
museomoderno: [400, 700, 800],
};
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
(a, b) => b.length - a.length
);
const PRECACHED = new Map(
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
);
/**
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
* read against the book's packages; null for any other file.
*/
export function bookFontFile(url) {
const name = url.split('/').pop() ?? '';
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
if (!match) return null;
const [, head, weight, style, ext] = match;
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
if (!pkg) return null;
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
}
/** Whether a file of the build goes into the install-time precache. */
export function keepInPrecache(url) {
const file = bookFontFile(url);
if (!file) return true;
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
const kept = PRECACHED.get(file.pkg);
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
}
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
export function withoutRuntimeFonts(entries) {
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
}
+165 -5
View File
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
// ── Studio ────────────────────────────────────────────────────────────────────
const rail = (page: Page) => page.locator('.st-rail')
const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true })
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true })
const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
const inspector = (page: Page) => page.locator('.st-inspector')
const studioMenu = (page: Page) => page.locator('.st-menu').last()
@@ -460,6 +460,20 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
const thumbs = (page: Page) => page.locator('.st-thumb-row')
/** The hit targets over the spread, one per element. */
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
/**
* The hit target of the first text on the spread on screen. The targets above the page are empty
* boxes, so the one over the drawn words is found by where the words are and marked for the locator.
*/
async function markTextTarget(page: Page): Promise<void> {
const words = page.locator('.st-sheet').getByText(/\p{L}{3,}/u).first()
await expect(words).toBeVisible()
await words.evaluate(el => {
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
hit?.setAttribute('data-help-text', '')
})
}
const textTarget = (page: Page) => page.locator('.st-sheet [data-help-text]')
async function studioSection(page: Page, name: string): Promise<void> {
await railButton(page, name).click()
@@ -1300,7 +1314,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
},
{
target: p => p.locator('.st-back'),
target: p => p.locator('.st-head-close'),
},
],
},
@@ -1431,7 +1445,7 @@ const SCRIPTS: Record<string, GuideScript> = {
},
{
prepare: async p => {
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
await settle(p)
},
@@ -1458,16 +1472,162 @@ const SCRIPTS: Record<string, GuideScript> = {
],
},
'studio-my-layouts': {
guide: guide('studio-my-layouts'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => railButton(p, 'Layouts'),
act: async p => { await studioSection(p, 'Layouts') },
},
{
// Pointed at rather than pressed, so the seed book keeps no layout of its own for the guides after this one.
prepare: async p => {
await sidePanel(p).getByRole('button', { name: 'Save this page as a layout' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => sidePanel(p).locator('.st-saved-layouts'),
},
],
},
'studio-theme': {
guide: guide('studio-theme'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Theme'),
act: async p => { await studioSection(p, 'Theme') },
},
{
target: p => sidePanel(p).locator('.st-theme-grid'),
act: async p => {
await sidePanel(p).locator('.st-theme-card').nth(1).click()
await settle(p)
},
},
{
prepare: async p => {
await sidePanel(p).getByRole('button', { name: 'Apply to the book' }).scrollIntoViewIfNeeded()
await settle(p)
},
target: p => sidePanel(p).locator('.st-theme-apply'),
},
],
},
'studio-stickers': {
guide: guide('studio-stickers'),
start: openStudio,
steps: [
{
prepare: async p => { await studioSection(p, 'Elements') },
target: p => sidePanel(p).getByRole('tab', { name: 'Stickers' }),
act: async p => {
await sidePanel(p).getByRole('tab', { name: 'Stickers' }).click()
await expect(sidePanel(p).locator('.st-shape-grid').first()).toBeVisible()
await settle(p)
},
},
{
target: p => sidePanel(p).locator('.st-shape-grid').first(),
},
],
},
'studio-travel': {
guide: guide('studio-travel'),
start: openStudio,
steps: [
{
target: p => railButton(p, 'Travel'),
act: async p => { await studioSection(p, 'Travel') },
act: async p => {
await studioSection(p, 'Travel')
await expect(sidePanel(p).locator('.st-travel-card').first()).toBeVisible({ timeout: 15_000 })
await settle(p)
},
},
{
target: sidePanel,
target: p => sidePanel(p).locator('.st-travel-grid').first(),
},
{
target: p => sidePanel(p).locator('.st-travel-card').first(),
},
],
},
'studio-text-effects': {
guide: guide('studio-text-effects'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
await markTextTarget(p)
},
target: p => textTarget(p),
act: async p => {
await textTarget(p).click()
await expect(inspector(p).getByRole('button', { name: 'Effects' })).toBeVisible()
await settle(p)
},
},
{
prepare: async p => {
const head = inspector(p).getByRole('button', { name: 'Effects' })
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click()
await head.scrollIntoViewIfNeeded()
await settle(p)
},
target: p => inspector(p).locator('.st-section').filter({ has: p.getByRole('button', { name: 'Effects' }) }),
},
],
},
'studio-background': {
guide: guide('studio-background'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => inspector(p).locator('.st-section').filter({ hasText: 'Page background' }).first(),
},
{
target: p => inspector(p).locator('.st-paint-tabs').first(),
},
],
},
'studio-layers': {
guide: guide('studio-layers'),
start: openStudio,
steps: [
{
prepare: async p => {
await studioSection(p, 'Pages')
await thumbs(p).nth(2).locator('.st-thumb').click()
await settle(p)
},
target: p => railButton(p, 'Layers'),
act: async p => {
await studioSection(p, 'Layers')
await expect(sidePanel(p).locator('.st-layer').first()).toBeVisible()
await settle(p)
},
},
{
target: p => sidePanel(p).locator('.st-layer').first(),
},
],
},
+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();
}
}
+31 -2
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,14 +46,42 @@
"predev": "node scripts/patch-maplibre.mjs"
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
@@ -67,6 +95,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",
@@ -79,7 +108,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"tz-lookup": "^6.1.25",
"zod": "^4.6.5",
"zod": "^4.6.7",
"zustand": "^5.0.15"
},
"devDependencies": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 29 KiB

+15 -15
View File
@@ -1,26 +1,26 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 858,
"@typescript-eslint/no-non-null-assertion": 384,
"@typescript-eslint/no-misused-promises": 849,
"no-console": 88,
"complexity": 229,
"complexity": 222,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 133,
"max-params": 35,
"trek/no-swallowed-catch": 130,
"max-params": 32,
"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,
"@typescript-eslint/no-unused-vars": 126,
"react-hooks/refs": 556,
"react-hooks/set-state-in-effect": 194,
"@typescript-eslint/no-floating-promises": 165,
"react-hooks/exhaustive-deps": 116,
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
"max-lines-per-function": 133,
"max-lines-per-function": 130,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 46,
"react-refresh/only-export-components": 45,
"unused-disable-directive": 6,
"max-depth": 21,
"react-hooks/void-use-memo": 1,
@@ -34,12 +34,12 @@
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1943,
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-non-null-assertion": 1929,
"@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,
+40 -8
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,30 @@
"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": "AI assistant home chat",
"hooks": ["src/components/Assistant/useAssistantHome.ts"],
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": [
"src/mobile/screens/assistant/MAssistantHomeScreen.tsx",
"src/mobile/screens/dashboard/MDashboard.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 +235,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 +273,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"]
},
@@ -573,7 +601,6 @@
"hooks": ["src/components/Planner/usePlaceActions.ts"],
"desktop": [
"src/components/Planner/PlaceInspector.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/pages/tripPlanner/useTripPlannerPage.ts"
],
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
@@ -658,6 +685,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 +1009,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).",
-18
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",
@@ -1137,8 +1123,6 @@
"src/components/shared/PasswordChecklist.tsx",
"src/components/shared/PlaceAvatar.test.tsx",
"src/components/shared/PlaceAvatar.tsx",
"src/components/shared/PlaceAvatarUpload.test.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/components/shared/PluginIcon.test.tsx",
"src/components/shared/PluginIcon.tsx",
"src/components/shared/PublicLanguagePicker.test.tsx",
@@ -2116,7 +2100,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 +2123,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",
+24 -10
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;
@@ -112,11 +117,6 @@ export const DYNAMIC_ALLOWED = [
template: 'settings.${mode}',
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
},
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
file,
template: 'journey.studio.${key}',
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
})),
{
file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
template: 'reservations.${s}',
@@ -139,6 +139,20 @@ export const DYNAMIC_ALLOWED = [
'memories.* key in en',
}))
),
{
file: 'components/Studio/stickerLibrary.ts',
template: 'journey.studio.sticker.${id}',
because:
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
'sticker the library holds against en',
},
{
file: 'components/Studio/shapes.ts',
template: 'journey.studio.shapeName.${shape}',
because:
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
'shape of BOOK_SHAPE_GROUPS',
},
];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+7 -7
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,
@@ -26,16 +26,16 @@
"packing.ts": 6,
"pdf.ts": 1,
"perm.ts": 4,
"places.ts": 10,
"places.ts": 9,
"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,
+3 -4
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,
@@ -39,8 +38,8 @@
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/StudioSidebar.tsx": 1,
"src/components/Studio/TravelElements.tsx": 1,
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
"src/components/Studio/travel/BadgeIcon.tsx": 1,
"src/components/Todo/useTodoList.ts": 1,
"src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.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
View File
@@ -116,7 +116,6 @@
"src/components/Vacay/useVacayCalendarLogic.ts",
"src/components/Vacay/useVacaySettings.ts",
"src/components/Vacay/useVacayUserPicker.ts",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/hooks/useAirtrailConnection.ts",
"src/hooks/useDawarichConnection.ts",
"src/hooks/useDawarichSuggestions.ts",
+3 -7
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,
@@ -19,7 +18,6 @@
"components/Journey/JourneyMap.tsx": 1,
"components/Journey/JourneyMapGL.tsx": 2,
"components/Journey/MobileEntryView.tsx": 1,
"components/Journey/PhotoLightbox.tsx": 3,
"components/Journey/journeyPhotoLayer.ts": 1,
"components/Layout/Navbar.tsx": 1,
"components/Layout/OfflineBanner.tsx": 1,
@@ -53,13 +51,12 @@
"components/Studio/BookSheetsView.tsx": 3,
"components/Studio/PageNumbers.tsx": 1,
"components/Studio/PeerCursors.tsx": 1,
"components/Studio/SpreadView.tsx": 8,
"components/Studio/StudioCanvas.tsx": 22,
"components/Studio/PhotoView.tsx": 4,
"components/Studio/SpreadView.tsx": 4,
"components/Studio/StudioCanvas.tsx": 12,
"components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/StudioSidebar.tsx": 3,
"components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
@@ -74,7 +71,6 @@
"components/shared/DetailShell.tsx": 1,
"components/shared/ElevationProfile.tsx": 2,
"components/shared/NavigationMenu.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 1,
"components/shared/SlidingTabs.tsx": 3,
"components/shared/StarRating.tsx": 3,
+5 -2
View File
@@ -1,4 +1,7 @@
{
"pages/tripPlanner/PlanMapStage.tsx": 4,
"mobile/components/trek3d/MascotClip.tsx": 1
"components/Studio/LineHandles.tsx": 1,
"components/Studio/StudioSelectionChrome.tsx": 2,
"components/Studio/inspector/FocalPad.tsx": 1,
"mobile/components/trek3d/MascotClip.tsx": 1,
"pages/tripPlanner/PlanMapStage.tsx": 4
}
+10 -12
View File
@@ -4,29 +4,27 @@
"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/RouteCalculator.ts": 1195,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2953,
"src/components/Planner/DayPlanSidebar.tsx": 2936,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/autoLayout.ts": 1691,
"src/components/Studio/bookTemplates.data.ts": 1309,
"src/components/Planner/PlaceFormModal.tsx": 1157,
"src/components/Planner/PlaceInspector.tsx": 1290,
"src/components/Studio/TravelElements.tsx": 1559,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1131,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 2087
"src/styles/studio.css": 1918
}
+1 -7
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,
@@ -52,7 +51,7 @@
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
"src/components/Planner/PlaceFormModal.test.tsx": 3,
"src/components/Planner/PlaceFormModal.tsx": 1,
"src/components/Planner/PlaceInspector.tsx": 17,
"src/components/Planner/PlaceInspector.tsx": 16,
"src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1,
"src/components/Planner/PlacesSidebar.tsx": 1,
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
@@ -83,9 +82,6 @@
"src/components/Settings/NotificationsTab.test.tsx": 1,
"src/components/Settings/PasskeysSection.tsx": 1,
"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,
@@ -100,7 +96,6 @@
"src/components/shared/CustomSelect.test.tsx": 1,
"src/components/shared/PlaceAvatar.test.tsx": 2,
"src/components/shared/PlaceAvatar.tsx": 1,
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
"src/hooks/useDawarichSuggestions.test.ts": 1,
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
@@ -145,7 +140,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,
+8 -12
View File
@@ -52,7 +52,7 @@
"components/Journey/moodConfig.ts": 5,
"components/Layout/BottomNav.tsx": 3,
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 8,
"components/Layout/InAppNotificationBell.tsx": 3,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21,
"components/Layout/NewVersionNotice.tsx": 3,
@@ -72,7 +72,7 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/Notifications/InAppNotificationItem.tsx": 8,
"components/Notifications/InAppNotificationItem.tsx": 1,
"components/PDF/TripPDF.tsx": 53,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
@@ -100,15 +100,13 @@
"components/Roadtrip/serviceMarker.ts": 1,
"components/Settings/useAppearanceEditor.ts": 5,
"components/Studio/BookSheetsView.tsx": 2,
"components/Studio/SpreadView.tsx": 3,
"components/Studio/PhotoView.tsx": 2,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 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/Studio/sidebar/contentDrops.ts": 2,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
"components/SystemNotices/SystemNoticeModal.tsx": 107,
@@ -134,7 +132,6 @@
"components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 3,
"components/shared/Skeleton.tsx": 2,
"components/shared/Spinner.tsx": 2,
@@ -154,7 +151,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,
@@ -254,7 +251,7 @@
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 27,
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23,
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
@@ -298,7 +295,7 @@
"pages/FilesPage.tsx": 11,
"pages/ForgotPasswordPage.tsx": 18,
"pages/HelpPage.tsx": 21,
"pages/InAppNotificationsPage.tsx": 9,
"pages/InAppNotificationsPage.tsx": 2,
"pages/JoinTripPage.tsx": 4,
"pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64,
@@ -310,7 +307,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,
+4 -3
View File
@@ -40,13 +40,14 @@
"components/Settings/AppearanceSettingsTab.tsx": 1,
"components/Settings/PasskeysSection.tsx": 1,
"components/Studio/BookSheetsView.tsx": 1,
"components/Studio/SpreadView.tsx": 2,
"components/Studio/PhotoView.tsx": 1,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 6,
"components/Studio/autoLayout.ts": 50,
"components/Studio/TravelElements.tsx": 3,
"components/Studio/peerColour.ts": 1,
"components/Studio/printSheets.ts": 4,
"components/Studio/travel/badgeLook.ts": 2,
"components/Trips/TripMemberAvatar.tsx": 2,
"components/Vacay/VacayPersons.tsx": 1,
"components/shared/DialogShell.tsx": 2,
+9
View File
@@ -65,6 +65,9 @@ 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'))
const MAssistantHomeScreen = lazyWithRetry(() => import('./mobile/screens/assistant/MAssistantHomeScreen'))
// 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 +411,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, and the home chat's outside it. */}
{!hideAuthedWidgets && isAuthenticated && (
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <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
+210
View File
@@ -0,0 +1,210 @@
/**
* 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']);
});
it('FE-AI-API-009: the home scope reaches the routes outside any trip', async () => {
const home = { kind: 'home' } as const;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => streamResponse('data: [DONE]\n\n'));
const signal = new AbortController().signal;
await assistantApi.chat(home, BODY, signal);
await assistantApi.decide(home, 7, { locale: 'en', timeZone: 'UTC', decisions: [] }, signal);
expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
'/api/assistant/chat',
'/api/assistant/threads/7/decisions',
]);
const hits: string[] = [];
server.use(
http.get('/api/assistant/threads', () => {
hits.push('threads');
return HttpResponse.json({ threads: [] });
}),
http.get('/api/assistant/threads/3', () => {
hits.push('thread');
return HttpResponse.json({ thread: { id: 3, title: 't', lastMessageAt: 'x' }, messages: [] });
}),
http.delete('/api/assistant/threads/3', () => {
hits.push('delete');
return HttpResponse.json({ success: true });
}),
http.post('/api/assistant/tool-calls/:id/undo', () => {
hits.push('undo');
return HttpResponse.json({ undone: true });
}),
http.post('/api/assistant/stop', () => {
hits.push('stop');
return HttpResponse.json({ success: true });
})
);
await assistantApi.threads(home);
await assistantApi.thread(home, 3);
await assistantApi.deleteThread(home, 3);
await assistantApi.undo(home, 'c1');
await assistantApi.stop(home);
expect(hits).toEqual(['threads', 'thread', 'delete', 'undo', 'stop']);
});
});
+239
View File
@@ -0,0 +1,239 @@
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 AssistantInsertFoundPlaceRequest,
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 and the home chat
* outside any one trip. Threads, turns, decisions, undo and stop exist under
* all three; 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);
if (target.kind === 'home') return '/assistant';
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),
// "+ Tour": a found place taken into the saved tour open in the planner, held as a change like "+ Day 3".
insertFoundPlace: (
tripId: number,
threadId: number,
body: AssistantInsertFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places/tour`, 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[] = []
+78 -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));
@@ -941,4 +712,33 @@ describe('AddonManager', () => {
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
])
);
render(<AddonManager />);
await screen.findByText('Todo List');
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
expect(screen.getByText('Budget')).toBeInTheDocument();
expect(screen.queryByText('Todo List')).toBeNull();
expect(screen.queryByText('Vacay')).toBeNull();
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
await user.click(screen.getByRole('button', { name: 'On' }));
expect(screen.getByText('Todo List')).toBeInTheDocument();
expect(screen.getByText('Vacay')).toBeInTheDocument();
expect(screen.queryByText('Budget')).toBeNull();
await user.click(screen.getByRole('button', { name: 'Off' }));
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
});
});
+159 -220
View File
@@ -1,9 +1,7 @@
import { useId, type ComponentType } from 'react'
import { useState, 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, Search } 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,11 +71,13 @@ 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,
} = useAddonManager()
const [query, setQuery] = useState('')
const [view, setView] = useState<AddonView>('all')
if (loading) {
return (
@@ -154,33 +153,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
}
@@ -201,17 +196,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
}
/* One column per type, side by side, instead of three stacked sections. Stacked, each
section opened its own set of columns and the tall tiles (Collab, Journey) set a
section's height while its neighbours ran out early. Side by side the column's
head carries the type, so the tiles need no badge of their own. */
/* A card per type, the way every other admin tab sets its cards: Trip in the
left column, Global and Integration in the right one, each card with the
dialogs' head band carrying the type, its count and what it means. Above
them one card says how many are on and finds an addon by name. */
const groups = [
{ 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">
@@ -221,201 +216,145 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span>
)
return (
<SettingsCard
icon={Puzzle}
title={t('admin.addons.title')}
hint={subtitle}
badge={totalCount > 0 ? (
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<span className="inline-flex">
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
</span>
</Tooltip>
) : undefined}
>
{addons.length === 0 ? (
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
) : (
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{groups.map(g => {
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>
</section>
)
})}
</div>
)}
</SettingsCard>
)
}
/** Whether an addon is shown under the search and the filter. */
const shown = (addon: Addon) => {
if (view === 'on' && !addon.enabled) return false
if (view === 'off' && addon.enabled) return false
const q = query.trim().toLocaleLowerCase()
if (!q) return true
const label = getAddonLabel(t, addon)
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
}
const card = (g: (typeof groups)[number]) => {
const meta = TYPE_META[g.key]
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
const visible = g.addons.filter(shown)
const ai = g.key === 'integration' && aiAddons.some(shown)
if (!visible.length && !ai) return null
const on = all.filter(a => a.enabled).length
return (
<SettingsCard
key={g.key}
icon={meta.icon}
title={t(meta.labelKey)}
hint={t(meta.hintKey)}
badge={(
<Tooltip label={t('admin.addons.group.count', { enabled: on, total: all.length })}>
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
</Tooltip>
)}
bodyClassName="p-3"
>
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{visible.map(tile)}
</div>
</SettingsCard>
)
}
const [first, ...rest] = groups
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
/** The head band of one type's column: icon tile, the type, its count and what it means. */
function GroupHead({ icon: Icon, label, hint, addons, t }: {
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
label: string
hint: string
addons: Addon[]
t: (key: string, params?: Record<string, unknown>) => string
}) {
const enabled = addons.filter(a => a.enabled).length
return (
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
<Icon size={13} strokeWidth={2} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
<>
<SettingsCard
icon={Puzzle}
title={t('admin.addons.title')}
hint={subtitle}
badge={totalCount > 0 ? (
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<span className="inline-flex">
<StatusPill>{enabled}/{addons.length}</StatusPill>
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
</span>
</Tooltip>
) : undefined}
>
{addons.length === 0
? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
: <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
</SettingsCard>
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
<div className="min-w-0">{cards.start}</div>
<div className="min-w-0">{cards.end}</div>
</div>
{/* Says what the type means: the only place that still explains it. */}
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
</div>
</div>
) : (
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}>
{t('admin.addons.noMatch')}
</p>
))}
</>
)
}
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'
type AddonView = 'all' | 'on' | 'off'
/**
* 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.
* The head card's body: a tile per type with how many of it are on, then a
* search over the addons' names and descriptions and a switch between all,
* the ones on and the ones off.
*/
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. */
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
groups: { key: string; addons: Addon[] }[]
query: string
onQuery: (q: string) => void
view: AddonView
onView: (v: AddonView) => void
t: (key: string, params?: Record<string, unknown>) => string
}) {
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 className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{groups.map(g => {
const meta = TYPE_META[g.key]
const Icon = meta.icon
const on = g.addons.filter(a => a.enabled).length
return (
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary">
<Icon size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
</span>
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
</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>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="relative flex min-w-0 flex-1 items-center">
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
<input
type="search"
value={query}
onChange={e => onQuery(e.target.value)}
placeholder={t('admin.addons.search')}
aria-label={t('admin.addons.search')}
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
style={fs(13, 'body')}
/>
</label>
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
{(['all', 'on', 'off'] as const).map(v => (
<button
key={v}
type="button"
onClick={() => onView(v)}
aria-pressed={view === v}
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
style={fs(12.5, 'body')}
>
{t(`admin.addons.filter.${v}`)}
</button>
))}
</div>
</div>
</>
)
}
@@ -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,355 @@
// 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,
webSearch: true,
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-001b: the web search is on as it ships and is switched off on the assistant tab', async () => {
const user = userEvent.setup();
renderDialog();
await user.click(await screen.findByRole('button', { name: 'AI Assistant' }));
const toggle = screen.getByRole('button', { name: 'Web search' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText(/through the search of the AI provider it talks to/)).toBeInTheDocument();
await user.click(toggle);
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, webSearch: false });
});
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,284 @@
import { Check, Loader2, Sparkles } from 'lucide-react';
import React, { useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import ToggleSwitch from '../../Settings/ToggleSwitch';
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, how long chats are kept, whether it searches the web. */
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>
{A.settings && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-3">
<span className="text-body font-medium text-content">{t('assistant.admin.webSearch')}</span>
<ToggleSwitch
on={A.settings.webSearch}
onToggle={() => A.setWebSearch(!A.settings?.webSearch)}
label={t('assistant.admin.webSearch')}
/>
</div>
<SettingsHint>{t('assistant.admin.webSearchHint')}</SettingsHint>
</div>
)}
</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,172 @@
// 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,
webSearch: true,
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,222 @@
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, retention and web search: 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) }),
setWebSearch: (webSearch: boolean) => void save({ webSearch }),
};
}
/** 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('');
});
});

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