Compare commits

..

2 Commits

Author SHA1 Message Date
Maurice 2654e651c7 style(i18n): wrap the long special theme hints the way Prettier wants 2026-10-18 13:04:19 +02:00
Maurice ce1fa68c84 feat(appearance): Halloween special theme with 3D decor and a costumed mascot
Adds a Special Themes row to Settings > Appearance > Theme. Halloween is a
scheme on the existing data-scheme axis that is always dark: the applier and
the boot script force .dark while it is on, and the color mode control is
dimmed with a hint.

- Palette for the whole app (surfaces, dashboard, Vacay glass, Journey,
  mobile chrome) in theme/special/halloween.tokens.css, loaded with index.css.
- Decor as a lazy chunk mounted by SpecialThemeLayer while the scheme is on:
  fog, embers, bat flocks, a passing ghost, corner cobwebs, spiders that drop
  on a thread and flee when poked, a pumpkin patch and an ember running along
  the navbar. Figures are pre-rendered 3D sprite sheets.
- Nothing is drawn over a map: the Atlas gets no decor, the trip planner only
  dresses its side panels. Marker clusters turn into lanterns.
- The 3D mascot plays its own Halloween clips in every scene (witch hat, a
  spider on the brim, a lit pumpkin beside it); the SVG fallback wears the
  same costume.
- Reduced motion stills everything, transparency off drops the fog, shared
  pages never show it.
- Navbar, bottom nav, notifications, Atlas and Collections read the dark flag
  off the .dark class instead of settings.dark_mode, so a forced dark theme
  reaches them.
2026-10-18 12:59:36 +02:00
2461 changed files with 9213 additions and 237495 deletions
-3
View File
@@ -26,8 +26,5 @@
*.eot binary
*.pdf binary
*.zip binary
# Ski area graphs (SGR1) as the Places host writes them, byte for byte
*.sgr binary
*.gz binary
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
server/tests/fixtures/gpx/bom-crlf.gpx -text
+131 -326
View File
@@ -9,73 +9,23 @@ permissions:
# analysis; pull requests get PR analysis with decoration on the PR itself
# (the scanner picks the context up from the Actions environment).
#
# Job names read "<package>: <what>", so the checks list on a PR sorts by
# package. What each job holds:
#
# Repo: Changed Areas decides what the change needs to run (below)
# Shared: i18n Parity every locale has en's files and keys
# Shared: Coverage shared's tests with coverage
# Server: Types builds shared and the server, typechecks shared,
# the server's src, tests and scripts, smokes the
# production require chain, the strict ratchet,
# the generated entities and Kysely types
# Server: Lint & Checks ESLint (typed), errors and warning counts; then
# Prettier, the plugin facts, the DB call graph,
# the shared ratchet code, the source ratchets
# (size, boundaries, dialect, tx, test SQL, test
# mocks, hand-built services, query API, MCP zod,
# error envelopes, response contracts), open
# request schemas, knip
# Server: Tests n/7 one slice of the server suite, with coverage
# Server: Coverage merges the slices, checks the per-domain
# thresholds, uploads lcov
# Server: S3 Contract (MinIO)
# the storage driver against MinIO
# Server: Postgres Probe the repository SQL against Postgres
# Client: Types & Checks typecheck and the strict ratchet; then the page
# pattern and the source ratchets (RTL, size,
# Prettier, layers, offline path, duplication,
# desktop/phone pairs, skips, i18n keys, theme
# tokens)
# Client: Lint ESLint (typed), errors and warning counts
# Client: Tests n/7 one slice of the client suite, with coverage
# Client: Coverage merges the slices, checks the thresholds,
# uploads lcov
# Client: Bundle the production build, one chunk per map engine
# Client: End-to-End the app in Chromium and WebKit
# Plugin SDK: Types & Coverage
# Repo: Docker Image builds the image and boots it until ready
# Repo: Helm Chart & Compose
# Repo: Sonar Scan SonarCloud analysis, not required
# ci-ok the one required check (below)
#
# Job layout:
# - Everything starts right after changes: the gates and the tests run side
# by side, so a run takes as long as its slowest job and not the gates
# plus the tests. The tests used to wait for the gates, which put ~9
# minutes in front of every test result. The gates were one job per
# package (~8-9 minutes each); each is now two jobs of ~4-5 minutes,
# split where the time is (the server's typechecks, the client's lint),
# so neither outlasts a test shard.
# - The client and server suites run in shards (client-tests,
# server-tests), each running a slice with coverage and uploading a blob
# report; client-coverage and server-coverage merge the blobs, check the
# thresholds against the whole and upload lcov. A red shard is usually a
# failing test, a red merge job usually a threshold. None of the other
# test jobs feeds the scan.
# - Job count: GitHub runs at most 20 jobs of the organisation at once and
# does not say in which order it starts queued ones. A code change starts
# the 14 shards and about a dozen other jobs; seven of those (parity, the
# SDK, the bundle, S3, Postgres, shared coverage, the chart) are done in
# a minute or two, so what queues at the start waits about that long. The
# jobs that run for minutes (shards, gates, e2e, the image) must not
# outnumber the 20 slots, or one of them waits for another to finish and
# the run grows by a whole job: a layout with six gate jobs did exactly
# that. Check the count before adding a long job or a shard.
# - Scan `needs:` the gates and the four coverage jobs. A coverage job
# failing must stop the scan — a missing lcov file would make every line
# of that package report as uncovered, and the dashboard would show a
# collapse that did not happen.
# Job layout — three tiers:
# - Preflight: plugin-facts, i18n-parity and the quality jobs
# (server-quality, client-quality, plugin-sdk-quality) hold the cheap
# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all
# of them, so a gate failure stops the run before the expensive coverage
# fan-out spends ~15 runner-minutes on a result the gates already sank.
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
# runs the MinIO contract suite. Coverage jobs run tests only — with the
# gates cleared in preflight, they can only fail because tests failed.
# client-e2e drives the real app in Chromium and WebKit, client-bundle
# builds the client and checks each map engine keeps a chunk of its own,
# and postgres-probe runs the dialect helpers and the repository
# statements it reaches against a Postgres service. None of them feeds the scan.
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
# must stop the scan — a missing lcov file would make every line of that
# package report as uncovered, and the dashboard would show a collapse
# that did not happen. (The preflight gates stop it transitively.)
# - Fork PRs and runs Dependabot triggers don't receive the repo's Actions
# secrets, so the two jobs that need them skip themselves: s3-contract
# (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN). ci-ok counts a skip as
@@ -88,7 +38,7 @@ permissions:
# required check that never reports blocks the PR for good.
# - docker-image builds the Dockerfile and boots the result, and
# deploy-manifests lints and renders the Helm chart and checks the
# compose files. Both run beside the gates rather than after them.
# compose files. Both run beside the preflight rather than after it.
# - ci-ok `needs:` every gate, test, image and chart job, and is the one
# check the branch ruleset requires. It fails when any of them failed or
# was cancelled and passes when they passed or were skipped (by changes,
@@ -119,7 +69,7 @@ concurrency:
jobs:
changes:
name: 'Repo: Changed Areas'
name: Changed Areas
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
@@ -194,8 +144,36 @@ jobs:
node scripts/ci/changed-areas.mjs --all
fi
plugin-facts:
name: Plugin Permission Facts
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace server
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
i18n-parity:
name: 'Shared: i18n Parity'
name: i18n Key Parity
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -211,8 +189,8 @@ jobs:
- name: Check i18n key parity
run: node shared/scripts/i18n-parity.mjs --strict
server-types:
name: 'Server: Types'
server-quality:
name: Server Types & Lint
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -261,11 +239,8 @@ jobs:
# constructor parameter does not break its hand-wired call sites at
# compile time: the argument arrives as undefined and throws at
# runtime, if anything reads it at all. This is the step that
# catches it. src and tests together are past 570,000 lines and need
# about 4.3 GB, over Node's default heap.
# catches it.
run: cd server && npm run typecheck:tests
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Typecheck entity generator
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
@@ -282,36 +257,6 @@ jobs:
# migration that changes a column regenerates it in the same change.
run: cd server && npm run check:db-types
server-lint-checks:
name: 'Server: Lint & Checks'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Build shared
run: npm run build --workspace=shared
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint
# Errors fail, and the warnings per rule may not pass
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
@@ -319,19 +264,8 @@ jobs:
# program, which outgrows Node's default ~4 GB heap on the runner.
env:
NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd server && npm run lint:warnings
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
- name: Formatting check
# Prettier over src/ and tests/: a file outside
# scripts/format-baseline.json must be formatted, and the list only
@@ -425,12 +359,12 @@ jobs:
# knip (knip.jsonc) over client, server and shared: unused files,
# exports, types and dependencies per file may not rise above
# scripts/ci/knip-baseline.json (scripts/ci/knip-ratchet.mjs). Here
# rather than in client-types-checks because it needs every workspace's
# dependencies, which the client jobs do not install.
# rather than in client-quality because it needs every workspace's
# dependencies, which only this job installs.
run: npm run lint:knip
client-types-checks:
name: 'Client: Types & Checks'
client-quality:
name: Client Types & Lint
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -446,7 +380,7 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
@@ -461,6 +395,15 @@ jobs:
# one included, has to be clean (scripts/strict-lint.mjs).
run: cd client && npm run lint:strict
- name: Lint
# Fails on any ESLint error, like lint:check did, and on any rule
# whose warning count rises above scripts/eslint-baseline.json.
# Typed linting (no-floating-promises, no-misused-promises) loads the
# whole TypeScript program, so it gets the heap the server lint gets.
env:
NODE_OPTIONS: --max-old-space-size=8192
run: cd client && npm run lint:warnings
- name: Page pattern check
run: cd client && npm run lint:pages
@@ -524,12 +467,15 @@ jobs:
# follow the user's scheme, accent and text size (scripts/theme-lint.mjs).
run: cd client && npm run theme:lint
client-lint:
name: 'Client: Lint'
plugin-sdk-quality:
name: Plugin SDK Types
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
@@ -537,40 +483,20 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
# installs from its own directory.
- name: Install dependencies
run: npm ci --workspace shared --workspace client
run: npm ci
- name: Build shared
run: npm run build --workspace=shared
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint
# Fails on any ESLint error, like lint:check did, and on any rule
# whose warning count rises above scripts/eslint-baseline.json.
# Typed linting (no-floating-promises, no-misused-promises) loads the
# whole TypeScript program, so it gets the heap the server lint gets.
env:
NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd client && npm run lint:warnings
- name: Typecheck
run: npm run typecheck
client-bundle:
name: 'Client: Bundle'
needs: changes
if: needs.changes.outputs.code == 'true'
name: Client Bundle
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -581,7 +507,7 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
@@ -597,11 +523,10 @@ jobs:
run: cd client && npm run check:gl-split
client-e2e:
name: 'Client: End-to-End'
needs: changes
if: needs.changes.outputs.code == 'true'
name: Client End-to-End
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -655,17 +580,17 @@ jobs:
retention-days: 7
s3-contract:
name: 'Server: S3 Contract (MinIO)'
name: S3 Driver Contract (MinIO)
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: changes
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and
# the AIStor container would fail to boot. A run Dependabot triggers gets
# only Dependabot secrets, so its PRs (same repo, not a fork) hit the same
# wall. Pushes and manual dispatches by anyone else do have secrets, so
# only those two are filtered out.
# only those two are filtered out. (The implicit success() on this
# expression also keeps the job skipped when a preflight fails.)
if: >-
needs.changes.outputs.code == 'true' &&
github.actor != 'dependabot[bot]' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
@@ -681,14 +606,14 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Check @swc/core's native binding loads
# The server's SWC transform (server/vitest.config.ts) needs it. The
# lockfile carries the Linux binary, so npm ci installs it; this only
# names the cause if a lockfile regenerated without it ever lands. (It
# used to be installed here with --legacy-peer-deps, which pruned
# eight peer packages and left the tests on a tree the lockfile does
# not describe.)
run: node -e "require('@swc/core').transformSync('let a = 1')"
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
@@ -746,10 +671,10 @@ jobs:
# artifact seeds the file and a change that fixes a statement can lower it
# without a local Postgres.
postgres-probe:
name: 'Server: Postgres Probe'
name: Postgres Probe
runs-on: ubuntu-24.04
timeout-minutes: 10
needs: changes
needs: [changes, plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
if: needs.changes.outputs.code == 'true'
services:
postgres:
@@ -802,20 +727,11 @@ jobs:
if-no-files-found: ignore
retention-days: 14
# The server suite in seven slices. vitest.shard.config.ts is vitest.config.ts
# without the thresholds and the coverage reports: a slice's blob carries
# its coverage to server-coverage, which checks the whole. fail-fast is off,
# so one red run shows every failing slice and not just the first.
server-tests:
name: 'Server: Tests ${{ matrix.shard }}/7'
needs: changes
if: needs.changes.outputs.code == 'true'
server-coverage:
name: Server Coverage
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -828,63 +744,20 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Check @swc/core's native binding loads
# The server's SWC transform (server/vitest.config.ts) needs it. The
# lockfile carries the Linux binary, so npm ci installs it; this only
# names the cause if a lockfile regenerated without it ever lands. (It
# used to be installed here with --legacy-peer-deps, which pruned
# eight peer packages and left the tests on a tree the lockfile does
# not describe.)
run: node -e "require('@swc/core').transformSync('let a = 1')"
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Run a slice of the tests with coverage
run: >-
cd server && npx vitest run --config vitest.shard.config.ts --coverage
--shard=${{ matrix.shard }}/7
--reporter=default --reporter=github-actions --reporter=blob
- name: Upload the blob report
uses: actions/upload-artifact@v6
with:
name: server-blob-${{ matrix.shard }}
path: server/.vitest-reports/
include-hidden-files: true
if-no-files-found: error
retention-days: 1
server-coverage:
name: 'Server: Coverage'
needs: server-tests
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Download the blob reports
uses: actions/download-artifact@v7
with:
pattern: server-blob-*
path: server/.vitest-reports/
merge-multiple: true
- name: Merge the slices and check the thresholds
# Every blob holds a whole coverage map, and the merge reads them all
# into one process.
env:
NODE_OPTIONS: --max-old-space-size=6144
run: cd server && npx vitest run --merge-reports --coverage
- name: Run tests with coverage
run: cd server && npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
@@ -893,18 +766,11 @@ jobs:
path: server/coverage/
retention-days: 1
# The client suite in seven slices, the same way as server-tests. The two
# suites cost about the same per slice at seven each (~4-6 minutes).
client-tests:
name: 'Client: Tests ${{ matrix.shard }}/7'
needs: changes
if: needs.changes.outputs.code == 'true'
client-coverage:
name: Client Coverage
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -919,56 +785,13 @@ jobs:
# @testing-library/react) in the tree without the re-resolution dance
# the folded coverage job used to need.
- name: Install dependencies
run: npm ci --workspace shared --workspace client
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run a slice of the tests with coverage
run: >-
cd client && npx vitest run --config vitest.shard.config.ts --coverage
--shard=${{ matrix.shard }}/7
--reporter=default --reporter=github-actions --reporter=blob
- name: Upload the blob report
uses: actions/upload-artifact@v6
with:
name: client-blob-${{ matrix.shard }}
path: client/.vitest-reports/
include-hidden-files: true
if-no-files-found: error
retention-days: 1
client-coverage:
name: 'Client: Coverage'
needs: client-tests
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
- name: Download the blob reports
uses: actions/download-artifact@v7
with:
pattern: client-blob-*
path: client/.vitest-reports/
merge-multiple: true
- name: Merge the slices and check the thresholds
# Every blob holds a whole coverage map, and the merge reads them all
# into one process.
env:
NODE_OPTIONS: --max-old-space-size=6144
run: cd client && npx vitest run --merge-reports --coverage
- name: Run tests with coverage
run: cd client && npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
@@ -978,11 +801,10 @@ jobs:
retention-days: 1
shared-coverage:
name: 'Shared: Coverage'
needs: changes
if: needs.changes.outputs.code == 'true'
name: Shared Coverage
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -1005,14 +827,11 @@ jobs:
path: shared/coverage/
retention-days: 1
# Typecheck and tests in one job: each takes seconds, and a runner of its
# own would cost more in setup than it saves.
plugin-sdk:
name: 'Plugin SDK: Types & Coverage'
needs: changes
if: needs.changes.outputs.code == 'true'
plugin-sdk-coverage:
name: Plugin SDK Coverage
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
defaults:
run:
working-directory: plugin-sdk
@@ -1031,9 +850,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Run tests with coverage
run: npm run test:coverage
@@ -1045,7 +861,7 @@ jobs:
retention-days: 1
scan:
name: 'Repo: Sonar Scan'
name: Scan
runs-on: ubuntu-24.04
timeout-minutes: 45
# Fork PRs and Dependabot runs don't receive SONAR_TOKEN, so skip the scan
@@ -1055,18 +871,9 @@ jobs:
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
# Coverage jobs failing must stop the scan: a missing lcov file would make
# every line of that package report as uncovered, and the dashboard would
# show a collapse that did not happen. The gates run beside the tests now,
# so they are listed here too, and a failed gate still stops the scan.
needs:
- i18n-parity
- server-types
- server-lint-checks
- client-types-checks
- client-lint
- server-coverage
- client-coverage
- shared-coverage
- plugin-sdk
# show a collapse that did not happen. The preflight gates already stop it
# transitively through these four, so they aren't repeated here.
needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage]
steps:
- uses: actions/checkout@v6
@@ -1127,7 +934,7 @@ jobs:
# reached dev unseen. It is separate from the vulnerability scan on purpose,
# so a newly published CVE cannot hide a broken build or the other way round.
docker-image:
name: 'Repo: Docker Image'
name: Docker Image
needs: changes
if: needs.changes.outputs.image == 'true'
runs-on: ubuntu-24.04
@@ -1204,7 +1011,7 @@ jobs:
# the output checked against the Kubernetes API schemas. Both tools come
# from their release pages, pinned by version and checksum.
deploy-manifests:
name: 'Repo: Helm Chart & Compose'
name: Helm Chart & Compose
needs: changes
if: needs.changes.outputs.deploy == 'true'
runs-on: ubuntu-24.04
@@ -1275,21 +1082,19 @@ jobs:
ci-ok:
needs:
- changes
- plugin-facts
- i18n-parity
- server-types
- server-lint-checks
- client-types-checks
- client-lint
- server-quality
- client-quality
- plugin-sdk-quality
- client-bundle
- client-e2e
- s3-contract
- postgres-probe
- server-tests
- server-coverage
- client-tests
- client-coverage
- shared-coverage
- plugin-sdk
- plugin-sdk-coverage
- docker-image
- deploy-manifests
# always(): without it a failed dependency would skip this job, and the
-2
View File
@@ -55,8 +55,6 @@ coverage
coverage-*/
*.lcov
.nyc_output
# Blob reports of a sharded vitest run (test.yml)
.vitest-reports
# Cache
.npm
+1 -3
View File
@@ -134,10 +134,8 @@ These principles come out of a full-repo audit and shape all new code:
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-lint-checks), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-quality), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
- **Job names read "package: what"** (`Server: Types`, `Client: Tests 3/7`), and the list at the top of `test.yml` says what each job holds. A new job follows the scheme and joins that list, and the job-count note there says when a long job needs a slot freed first.
- **Tests run beside the gates, in shards.** The client and server suites run in slices on parallel runners (`client-tests`, `server-tests`, through each package's `vitest.shard.config.ts`), and `client-coverage`/`server-coverage` merge their blob reports and check the thresholds against the whole. A red slice is usually a failing test, a red merge job usually a threshold.
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
+2 -2
View File
@@ -102,8 +102,8 @@ env:
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
# Defaults to 25000.
# TREK_PLACES_ENABLED: "false"
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads,
# and the OpenStreetMap ways and horse routes Tours reads) asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
# here. Defaults to on.
+1 -1
View File
@@ -46,8 +46,8 @@
"predev": "node scripts/patch-maplibre.mjs"
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/creepster": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0",
+2
View File
@@ -24,6 +24,8 @@
if (isShared) dark = false;
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
else dark = s.darkMode === true || s.darkMode === 'dark';
// Special themes (APPEARANCE_SPECIAL_SCHEMES in @trek/shared) are dark-only.
if (!isShared && s.scheme === 'halloween') dark = true;
root.classList.toggle('dark', dark);
var scheme = isShared ? 'default' : s.scheme;
+6 -6
View File
@@ -1,22 +1,22 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 870,
"@typescript-eslint/no-misused-promises": 875,
"no-console": 88,
"complexity": 230,
"@typescript-eslint/no-explicit-any": 147,
"trek/no-swallowed-catch": 133,
"max-params": 35,
"trek/store-without-selector": 69,
"react-hooks/refs": 557,
"react-hooks/set-state-in-effect": 196,
"react-hooks/refs": 559,
"react-hooks/set-state-in-effect": 197,
"@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 121,
"@typescript-eslint/no-unused-vars": 127,
"@typescript-eslint/no-unused-vars": 130,
"react-hooks/immutability": 28,
"max-lines-per-function": 133,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 12,
"react-hooks/preserve-manual-memoization": 15,
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 41,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1945,
"@typescript-eslint/no-non-null-assertion": 1946,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+3 -68
View File
@@ -16,17 +16,12 @@
"feature": "Addon manager",
"hooks": [
"src/components/Admin/addons/addonModel.ts",
"src/components/Admin/addons/useAddonManager.ts"
"src/components/Admin/addons/useAddonManager.ts",
"src/components/Admin/addons/useLlmParsingConfig.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"],
@@ -63,21 +58,6 @@
"desktop": ["src/pages/admin/AdminUsersTab.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminUsersSection.tsx"]
},
{
"feature": "AI assistant chat",
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
},
{
"feature": "Assistant hints",
"hooks": ["src/components/Assistant/useAssistantHints.ts"],
"desktop": ["src/components/Assistant/AssistantHeader.tsx", "src/components/Assistant/AssistantLauncher.tsx"],
"mobile": [
"src/mobile/screens/trip/assistant/MAssistantScreen.tsx",
"src/mobile/screens/trip/sheets/MMehrSheet.tsx"
]
},
{
"feature": "Atlas country/region action sheet",
"hooks": ["src/pages/atlas/useAtlasCountryActions.ts"],
@@ -243,15 +223,6 @@
"desktop": ["src/pages/CollectionsPage.tsx"],
"mobile": ["src/mobile/screens/collections/MCollections.tsx"]
},
{
"feature": "Collection tours (LIB-03): useCollectionTours, useCollectionTourTakeOver",
"hooks": [
"src/components/Tours/collection/useCollectionTours.ts",
"src/components/Tours/collection/useCollectionTourTakeOver.ts"
],
"desktop": ["src/components/Tours/collection/CollectionToursSection.tsx"],
"mobile": ["src/mobile/screens/collections/MCollTours.tsx"]
},
{
"feature": "Copy collection places to a trip",
"hooks": ["src/components/Collections/useTripCopyPicker.ts"],
@@ -419,18 +390,6 @@
"src/mobile/screens/journey/mobileJourneyMeta.ts"
]
},
{
"feature": "Journey day take-over: useJourneyDayImport",
"hooks": ["src/components/Journey/useJourneyDayImport.ts"],
"desktop": ["src/components/Journey/JourneyDayImportDialog.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyDayImportSheet.tsx"]
},
{
"feature": "Journey day recording: useJourneyRecordingDay",
"hooks": ["src/components/Journey/useJourneyRecordingDay.ts"],
"desktop": ["src/components/Journey/JourneyRecordingDialog.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyRecordingSheet.tsx"]
},
{
"feature": "Journey detail page: useJourneyDetail",
"hooks": ["src/pages/journeyDetail/useJourneyDetail.ts"],
@@ -612,24 +571,6 @@
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
},
{
"feature": "Public transit for one leg, between two city walks from the first one's end, and a journey late for the next walk",
"hooks": ["src/components/Planner/useTransitLegPlanner.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
},
{
"feature": "A tour's start and end in the day plan, its end worked out from its time, and an end that runs into the next timed item",
"hooks": ["src/components/Tours/tourDayTime.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
},
{
"feature": "A multi-day tour put on a day: the whole tour there, or a stage a day from that day on",
"hooks": ["src/components/Tours/stages/stageSpread.ts"],
"desktop": ["src/components/Tours/stages/TourDayChoiceDialog.tsx", "src/components/Tours/stages/TourDayAddButton.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MTourDayChoiceSheet.tsx"]
},
{
"feature": "Reservation form incl. hotel accommodation creation",
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
@@ -672,12 +613,6 @@
"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"],
@@ -939,7 +874,6 @@
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
@@ -978,6 +912,7 @@
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
"src/mobile/screens/trip/sheets/MImportSheet.tsx": "Menu that routes to ImpFileStep and ImpListStep, with no logic of its own.",
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
"src/mobile/screens/trip/sheets/MMehrSheet.tsx": "Phone dock overflow menu that only opens tabs and sheets.",
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
+2
View File
@@ -729,6 +729,8 @@
"src/components/Planner/parsedItemToDraft.ts",
"src/components/Planner/placeAmap.test.ts",
"src/components/Planner/placeAmap.ts",
"src/components/Planner/placeCategoryGuess.test.ts",
"src/components/Planner/placeCategoryGuess.ts",
"src/components/Planner/placeCoMaps.test.ts",
"src/components/Planner/placeCoMaps.ts",
"src/components/Planner/placeDialogParts.test.tsx",
+1 -1
View File
@@ -30,7 +30,7 @@
"planner.ts": 53,
"reservations.ts": 21,
"roadtrip.ts": 38,
"settings.ts": 37,
"settings.ts": 40,
"share.ts": 7,
"shared.ts": 2,
"storage.ts": 16,
+4 -91
View File
@@ -2,7 +2,6 @@
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
*/
import { ESLint } from 'eslint';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import ts from 'typescript';
import {
@@ -138,90 +137,6 @@ export async function lintClient(root) {
}));
}
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
function gitIn(root, args) {
try {
return execFileSync('git', args, {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 1 << 28,
});
} catch {
return null;
}
}
/** The messages of one rule in one area of a single file's lint result. */
function ofRule(results, rule, area) {
const result = results[0];
if (!result) return [];
const pool =
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
return pool.filter((m) => ruleOf(m) === rule);
}
/**
* Where a grown rule's new warnings sit: every file among `keys` (the files
* holding the rule now) that differs from `ref` and holds more of the rule
* than it did there, each of its warnings of the rule as `file:line message`.
* The count alone names no file, and the files holding the most are old ones;
* this names the file that tipped it. Null when `ref` is not at hand (a clone
* without it), and the report then keeps to the files holding the most.
*/
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
if (changed === null) return null;
const differs = new Set(changed.split('\n').filter(Boolean));
const found = await Promise.all(
keys
.filter((key) => differs.has(key))
.map(async (key) => {
const path = join(root, key);
// A typed rule can trip over a text that is not the file on disk (the
// base version); then the file's own warnings are told, marked so.
const lint = async (text) => {
try {
return { messages: ofRule(await lintText(text, path), rule, area) };
} catch {
return null;
}
};
const now = await lint(readText(path));
if (!now) return [];
const before = git(root, ['show', `${ref}:./${key}`]);
const was = before === null ? { messages: [] } : await lint(before);
if (was && now.messages.length <= was.messages.length) return [];
const note = was ? '' : ' (the base version could not be linted)';
return now.messages.map((m) => `${key}:${m.line ?? 0} ${m.message}${note}`);
})
);
return found.flat();
}
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
async function reportNew(grown, { root, files, lintText, error }) {
if (!grown.length || !lintText) return;
const found = await Promise.all(
grown.map(({ area, rule }) => newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText }))
);
grown.forEach(({ area, rule }, i) => {
const lines = found[i];
if (!lines?.length) return;
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
for (const line of lines) error(` ${line}`);
});
}
/** Lints one file's text with the client's own config, for newWarningsOf. */
function lintTextClient(root) {
const eslint = new ESLint({ cwd: root });
return (text, filePath) => eslint.lintText(text, { filePath });
}
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
/**
@@ -233,7 +148,6 @@ export async function check({
baselinePath = join(root, 'scripts/eslint-baseline.json'),
update = false,
lint = lintClient,
lintText = lint === lintClient ? lintTextClient : null,
log = console.log,
error = console.error,
}) {
@@ -246,13 +160,13 @@ export async function check({
}
for (const line of errors) error(`FAIL ${line}`);
const grown = [];
let grown = 0;
let stale = 0;
for (const area of AREAS) {
for (const [rule, n] of Object.entries(counts[area])) {
const allowed = baseline[area][rule] ?? 0;
if (n <= allowed) continue;
grown.push({ area, rule });
grown++;
const top = Object.entries(files[area][rule])
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
@@ -268,8 +182,7 @@ export async function check({
});
stale += fallen.length;
}
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
if (grown.length) {
if (grown) {
error(
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
'Run npx eslint <file> to see each one.'
@@ -278,5 +191,5 @@ export async function check({
log(
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
);
return errors.length || grown.length || stale ? 1 : 0;
return errors.length || grown || stale ? 1 : 0;
}
+6 -6
View File
@@ -5,16 +5,16 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapView.tsx": 1409,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2291,
"src/components/Map/MapViewGL.tsx": 2299,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
"src/components/Planner/DayPlanSidebar.tsx": 2963,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceFormModal.tsx": 1193,
"src/components/Planner/DayPlanSidebar.tsx": 2971,
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
"src/components/Planner/PlaceFormModal.tsx": 1250,
"src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/autoLayout.ts": 1691,
@@ -24,7 +24,7 @@
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1140,
"src/pages/TripPlannerPage.tsx": 1142,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
+1
View File
@@ -148,6 +148,7 @@
"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,
+3 -3
View File
@@ -55,7 +55,7 @@
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 10,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21,
"components/Layout/Navbar.tsx": 22,
"components/Layout/NewVersionNotice.tsx": 3,
"components/Layout/OfflineBanner.tsx": 2,
"components/Layout/PageSidebar.tsx": 3,
@@ -156,7 +156,7 @@
"mobile/components/MToastHost.tsx": 2,
"mobile/components/MUserPickerList.tsx": 1,
"mobile/screens/admin/MAdmin.tsx": 9,
"mobile/screens/admin/MAdminAddonManager.tsx": 13,
"mobile/screens/admin/MAdminAddonManager.tsx": 26,
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
@@ -269,7 +269,7 @@
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
+2 -8
View File
@@ -33,6 +33,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
import NewVersionNotice from './components/Layout/NewVersionNotice'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
import SpecialThemeLayer from './theme/special/SpecialThemeLayer'
// Notice action registrations (side-effect imports):
import './pages/Trips/noticeActions.js'
import { managedRoutes } from './managed'
@@ -62,8 +63,6 @@ const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
// The desktop assistant window: its own chunk, and nothing at all until a connection is set up.
const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost'))
// The ten phone screens are chunks of their own, alongside the desktop pages
// rather than inside them. Each page used to import its M screen statically and
@@ -405,14 +404,9 @@ export default function App() {
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
{/* The phone has its own assistant screen inside the trip. */}
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
<Suspense fallback={null}><AssistantHost /></Suspense>
</ErrorBoundary>
)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:special-theme" fallback={null}><SpecialThemeLayer /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
-133
View File
@@ -1,133 +0,0 @@
/**
* FE-AI-API-001 to FE-AI-API-007
*
* The two streaming calls bypass axios, so they carry by hand what the axios
* instance would add (the cookie, the socket id) and turn a refusal before the
* stream into a typed error the chat can name. The refusal's code comes from
* the body when the server names one, and from the status otherwise.
*/
import type { AssistantChatRequest } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { assistantApi, AssistantRequestError } from './assistant';
import { getSocketId } from './websocket';
const BODY: AssistantChatRequest = { locale: 'en', timeZone: 'Europe/Berlin', threadId: null, text: 'Hi' };
function streamResponse(text: string): Response {
const bytes = new TextEncoder().encode(text);
let sent = false;
return {
ok: true,
status: 200,
body: {
getReader: () => ({
read: async () => {
if (sent) return { done: true, value: undefined };
sent = true;
return { done: false, value: bytes };
},
cancel: async () => {},
}),
},
} as unknown as Response;
}
function refusal(status: number, body: unknown): Response {
return {
ok: false,
status,
body: null,
json: async () => {
if (body === undefined) throw new SyntaxError('not json');
return body;
},
} as unknown as Response;
}
async function refusedWith(res: Response) {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(res);
try {
await assistantApi.chat(4, BODY, new AbortController().signal);
} catch (err) {
return err;
}
throw new Error('expected a refusal');
}
afterEach(() => vi.restoreAllMocks());
describe('assistantApi streams', () => {
it('FE-AI-API-001: posts the message as JSON with the cookie and the socket id, and yields the chunks', async () => {
vi.mocked(getSocketId).mockReturnValueOnce('sock-1');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(streamResponse('data: {"type":"start","messageId":"m1"}\n\ndata: [DONE]\n\n'));
const signal = new AbortController().signal;
const chunks: unknown[] = [];
for await (const chunk of await assistantApi.chat(4, BODY, signal)) chunks.push(chunk);
expect(chunks).toEqual([{ type: 'start', messageId: 'm1' }]);
const [url, init] = fetchSpy.mock.calls[0] ?? [];
expect(url).toBe('/api/trips/4/assistant/chat');
expect(init).toMatchObject({ method: 'POST', credentials: 'include', signal, body: JSON.stringify(BODY) });
expect(init?.headers).toMatchObject({
'content-type': 'application/json',
accept: 'text/event-stream',
'X-Socket-Id': 'sock-1',
});
});
it('FE-AI-API-002: sends decisions to the thread they belong to', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamResponse('data: [DONE]\n\n'));
const body = { locale: 'de', timeZone: 'UTC', decisions: [{ toolCallId: 'c1', approved: true }] };
await assistantApi.decide(4, 7, body, new AbortController().signal);
expect(fetchSpy.mock.calls[0]?.[0]).toBe('/api/trips/4/assistant/threads/7/decisions');
});
it('FE-AI-API-003: a refusal names the code the body gives', async () => {
const err = await refusedWith(refusal(429, { error: 'Daily limit reached', code: 'daily_limit' }));
expect(err).toBeInstanceOf(AssistantRequestError);
expect(err).toMatchObject({ code: 'daily_limit', status: 429 });
});
it('FE-AI-API-004: without a known code the status decides', async () => {
expect(await refusedWith(refusal(429, { error: 'slow down' }))).toMatchObject({ code: 'too_many_requests' });
expect(await refusedWith(refusal(409, { error: 'x', code: 'conflict' }))).toMatchObject({ code: 'busy' });
expect(await refusedWith(refusal(502, undefined))).toMatchObject({ code: 'provider_unavailable' });
expect(await refusedWith(refusal(500, { error: 'boom' }))).toMatchObject({ code: 'generic' });
});
it('FE-AI-API-005: an accepted request without a body is a generic failure, not a silent empty answer', async () => {
const err = await refusedWith({ ok: true, status: 200, body: null } as unknown as Response);
expect(err).toMatchObject({ code: 'generic', status: 200 });
});
});
describe('assistantApi JSON calls', () => {
it('FE-AI-API-006: undo posts an empty JSON body to the escaped tool call id', async () => {
let seen: { path: string; body: unknown } | null = null;
server.use(
http.post('/api/trips/4/assistant/tool-calls/:id/undo', async ({ request, params }) => {
seen = { path: String(params.id), body: await request.json() };
return HttpResponse.json({ undone: true });
})
);
await expect(assistantApi.undo(4, 'a/b')).resolves.toEqual({ undone: true });
expect(seen).toEqual({ path: 'a/b', body: {} });
});
it('FE-AI-API-007: stop posts an empty JSON body', async () => {
let body: unknown = null;
server.use(
http.post('/api/trips/4/assistant/stop', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
})
);
await expect(assistantApi.stop(4)).resolves.toEqual({ success: true });
expect(body).toEqual({});
});
});
-189
View File
@@ -1,189 +0,0 @@
import {
ASSISTANT_ERROR_CODES,
type AiAdminConnectionResponse,
type AiAdminSettings,
type AiApiKeyConnectRequest,
type AiAuthStartResponse,
type AiChatgptFinishRequest,
type AiClaudeConnectorResponse,
type AiConnectionScope,
type AiConnectionView,
type AiInstanceConnectionRequest,
type AiModelListResponse,
type AiOpenrouterFinishRequest,
type AiPrivacyAckRequest,
type AiStatusResponse,
type AssistantAddFoundPlaceRequest,
type AssistantAddFoundPlaceResponse,
type AssistantChatRequest,
type AssistantDecisionsRequest,
type AssistantErrorCode,
type AssistantShareRequest,
type AssistantThreadListResponse,
type AssistantThreadResponse,
type AssistantUndoResponse,
type CollabMessageResponse,
type SuccessResponse,
type UiStreamChunk,
} from '@trek/shared';
import apiClient from './client';
import { parseUiMessageStream } from './uiMessageStream';
import { getSocketId } from './websocket';
/**
* The trip assistant and the AI connections behind it, in their own file
* rather than in the `client.ts` god module.
*
* The two streaming calls (a new message, and the decisions on held changes)
* go through `fetch`, not axios: the answer is a server-sent event stream that
* runs for as long as the model works, and axios would buffer it whole behind
* its 8 s timeout. Everything else is plain JSON through `apiClient`.
*/
/** A refusal the server answered before the stream opened, named by its `AssistantErrorCode`. */
export class AssistantRequestError extends Error {
readonly code: AssistantErrorCode;
readonly status: number;
constructor(code: AssistantErrorCode, status: number) {
super(`Assistant request refused: ${code}`);
this.name = 'AssistantRequestError';
this.code = code;
this.status = status;
}
}
const KNOWN_CODES: ReadonlySet<string> = new Set(ASSISTANT_ERROR_CODES);
/**
* The code of a refusal: the body's `code` when it names one, otherwise what
* the status alone can say.
*/
async function refusalCode(res: Response): Promise<AssistantErrorCode> {
try {
const body: unknown = await res.json();
const code = (body as { code?: unknown } | null)?.code;
if (typeof code === 'string' && KNOWN_CODES.has(code)) return code as AssistantErrorCode;
} catch {
// Not JSON (a proxy's error page); the status below still says something.
}
// A bare 429 is TREK's own limit or a proxy's; a provider's limit always names its code.
if (res.status === 429) return 'too_many_requests';
if (res.status === 409) return 'busy';
if (res.status === 502 || res.status === 503 || res.status === 504) return 'provider_unavailable';
return 'generic';
}
async function openStream(path: string, body: unknown, signal: AbortSignal): Promise<AsyncGenerator<UiStreamChunk>> {
// A file goes as multipart, where the browser sets the content type with its boundary.
const form = body instanceof FormData;
const headers: Record<string, string> = form
? { accept: 'text/event-stream' }
: { 'content-type': 'application/json', accept: 'text/event-stream' };
const socketId = getSocketId();
if (socketId) headers['X-Socket-Id'] = socketId;
const res = await fetch(`/api${path}`, {
method: 'POST',
credentials: 'include',
headers,
body: form ? body : JSON.stringify(body),
signal,
});
if (!res.ok) throw new AssistantRequestError(await refusalCode(res), res.status);
if (!res.body) throw new AssistantRequestError('generic', res.status);
return parseUiMessageStream(res.body);
}
const tripBase = (tripId: number) => `/trips/${tripId}/assistant`;
/**
* For the calls that make the server go and ask the provider (listing models,
* testing a key, exchanging a sign-in code): the shared 8 s is TREK's own
* budget and too short for a provider's.
*/
const viaProvider = { timeout: 30_000 };
export const assistantApi = {
threads: (tripId: number): Promise<AssistantThreadListResponse> =>
apiClient.get(`${tripBase(tripId)}/threads`).then((r) => r.data),
thread: (tripId: number, threadId: number): Promise<AssistantThreadResponse> =>
apiClient.get(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
deleteThread: (tripId: number, threadId: number): Promise<SuccessResponse> =>
apiClient.delete(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
undo: (tripId: number, toolCallId: string): Promise<AssistantUndoResponse> =>
apiClient.post(`${tripBase(tripId)}/tool-calls/${encodeURIComponent(toolCallId)}/undo`, {}).then((r) => r.data),
// Ends this user's running turn, wherever it runs (another tab included).
stop: (tripId: number): Promise<SuccessResponse> =>
apiClient.post(`${tripBase(tripId)}/stop`, {}).then((r) => r.data),
// A place a search found, suggested for a day: answers with the message holding the change.
addFoundPlace: (
tripId: number,
threadId: number,
body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
// An answer posted to the trip chat, marked as shared from the assistant; the client's socket id skips the echo.
share: (
tripId: number,
threadId: number,
messageId: string,
body: AssistantShareRequest
): Promise<CollabMessageResponse> =>
apiClient
.post(`${tripBase(tripId)}/threads/${threadId}/messages/${encodeURIComponent(messageId)}/share`, body)
.then((r) => r.data),
chat: (tripId: number, body: AssistantChatRequest, signal: AbortSignal) =>
openStream(`${tripBase(tripId)}/chat`, body, signal),
decide: (tripId: number, threadId: number, body: AssistantDecisionsRequest, signal: AbortSignal) =>
openStream(`${tripBase(tripId)}/threads/${threadId}/decisions`, body, signal),
// A booking file: the server reads it and proposes each booking it finds as a change.
attach: (tripId: number, form: FormData, signal: AbortSignal) =>
openStream(`${tripBase(tripId)}/attachments`, form, signal),
};
/** A user's own connection, and what the assistant runs on for them. */
export const aiApi = {
status: (): Promise<AiStatusResponse> => apiClient.get('/ai/status').then((r) => r.data),
models: (): Promise<AiModelListResponse> => apiClient.get('/ai/models', viaProvider).then((r) => r.data),
// Saving tests the key against the provider first.
connectKey: (body: AiApiKeyConnectRequest): Promise<AiConnectionView> =>
apiClient.put('/ai/connection', body, viaProvider).then((r) => r.data),
setModel: (model: string): Promise<AiConnectionView> =>
apiClient.patch('/ai/connection/model', { model }).then((r) => r.data),
disconnect: (): Promise<SuccessResponse> => apiClient.delete('/ai/connection').then((r) => r.data),
ackPrivacy: (body: AiPrivacyAckRequest): Promise<SuccessResponse> =>
apiClient.post('/ai/privacy-ack', body).then((r) => r.data),
chatgptStart: (scope: AiConnectionScope = 'user'): Promise<AiAuthStartResponse> =>
apiClient
.post(scope === 'instance' ? '/admin/ai/connect/chatgpt/start' : '/ai/connect/chatgpt/start', {})
.then((r) => r.data),
chatgptFinish: (body: AiChatgptFinishRequest): Promise<AiConnectionView> =>
apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data),
chatgptSignOut: (): Promise<SuccessResponse> =>
apiClient.post('/ai/connect/chatgpt/sign-out', {}).then((r) => r.data),
openrouterStart: (scope: AiConnectionScope): Promise<AiAuthStartResponse> =>
apiClient
.post(scope === 'instance' ? '/admin/ai/connect/openrouter/start' : '/ai/connect/openrouter/start', { scope })
.then((r) => r.data),
openrouterFinish: (body: AiOpenrouterFinishRequest): Promise<AiConnectionView> =>
apiClient.post('/ai/connect/openrouter/finish', body, viaProvider).then((r) => r.data),
};
/** The connection the instance provides for everyone, and the assistant's admin settings. */
export const aiAdminApi = {
settings: (): Promise<AiAdminSettings> => apiClient.get('/admin/ai/settings').then((r) => r.data),
saveSettings: (body: AiAdminSettings): Promise<AiAdminSettings> =>
apiClient.put('/admin/ai/settings', body).then((r) => r.data),
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
};
export const claudeConnectorApi = {
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
};
-54
View File
@@ -1,54 +0,0 @@
// FE-API-COLLECTION-TOURS-001 to FE-API-COLLECTION-TOURS-002
//
// LIB-03: every method pins its verb, its path and its body, and hands back what
// the server answered.
import { http, HttpResponse, type JsonBodyType } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { collectionToursApi } from './collectionTours';
const BASE = '/api/addons/collections/3/tours';
let calls: Array<{ method: string; path: string; body: unknown }> = [];
beforeEach(() => {
calls = [];
});
function record(data: JsonBodyType | null) {
return async ({ request }: { request: Request }) => {
const text = await request.text();
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
return data === null ? new HttpResponse(null, { status: 200 }) : HttpResponse.json(data);
};
}
describe('collectionToursApi', () => {
it('FE-API-COLLECTION-TOURS-001: lists, reads, keeps and removes the tours of a collection', async () => {
server.use(
http.get(BASE, record({ tours: [] })),
http.get(`${BASE}/4`, record({ tour: { id: 4 }, line: [], points: [], trips: [] })),
http.post(BASE, record({ tour: { id: 5 } })),
http.delete(`${BASE}/4`, record({ success: true }))
);
expect(await collectionToursApi.list(3)).toEqual({ tours: [] });
expect((await collectionToursApi.detail(3, 4)).tour).toEqual({ id: 4 });
expect(await collectionToursApi.save(3, { source_trip_id: 2, source_place_id: 7, name: 'Kept' })).toEqual({
tour: { id: 5 },
});
expect(await collectionToursApi.remove(3, 4)).toBeUndefined();
expect(calls).toEqual([
{ method: 'GET', path: BASE, body: null },
{ method: 'GET', path: `${BASE}/4`, body: null },
{ method: 'POST', path: BASE, body: { source_trip_id: 2, source_place_id: 7, name: 'Kept' } },
{ method: 'DELETE', path: `${BASE}/4`, body: null },
]);
});
it('FE-API-COLLECTION-TOURS-002: takes a kept tour into a trip with the choice about its points', async () => {
server.use(http.post('/api/trips/6/tours/from-collection', record({ tour: { place_id: 9 }, waypoints: [] })));
const body = { collection_id: 3, collection_tour_id: 4, include_waypoints: false };
expect((await collectionToursApi.takeOver(6, body)).tour).toEqual({ place_id: 9 });
expect(calls).toEqual([{ method: 'POST', path: '/api/trips/6/tours/from-collection', body }]);
});
});
-25
View File
@@ -1,25 +0,0 @@
import type {
CollectionTourDetailResponse,
CollectionTourListResponse,
CollectionTourSaveRequest,
CollectionTourSaveResponse,
CollectionTourTakeOverRequest,
TourCreateResponse,
} from '@trek/shared';
import apiClient from './client';
const base = (collectionId: number) => `/addons/collections/${collectionId}/tours`;
/** LIB-03: the whole tours a collection keeps, keeping one, removing one and taking one into a trip. */
export const collectionToursApi = {
list: (collectionId: number, signal?: AbortSignal): Promise<CollectionTourListResponse> =>
apiClient.get(base(collectionId), { signal }).then((r) => r.data),
detail: (collectionId: number, tourId: number, signal?: AbortSignal): Promise<CollectionTourDetailResponse> =>
apiClient.get(`${base(collectionId)}/${tourId}`, { signal }).then((r) => r.data),
save: (collectionId: number, body: CollectionTourSaveRequest): Promise<CollectionTourSaveResponse> =>
apiClient.post(base(collectionId), body).then((r) => r.data),
remove: (collectionId: number, tourId: number): Promise<void> =>
apiClient.delete(`${base(collectionId)}/${tourId}`).then(() => undefined),
takeOver: (tripId: number, body: CollectionTourTakeOverRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/from-collection`, body).then((r) => r.data),
};
-34
View File
@@ -1,34 +0,0 @@
// FE-API-HORSE-001 to FE-API-HORSE-002
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { horseRoutesApi } from './horseRoutes';
const reply = { routes: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
const BBOX = '47.10000,11.20000,47.30000,11.50000';
describe('horseRoutesApi.list', () => {
it('FE-API-HORSE-001: asks GET /api/trips/:tripId/tours/horse-routes with the view and returns the body', async () => {
let url = '';
server.use(
http.get('/api/trips/7/tours/horse-routes', ({ request }) => {
url = request.url;
return HttpResponse.json(reply);
})
);
await expect(horseRoutesApi.list(7, BBOX)).resolves.toEqual(reply);
expect(new URL(url).searchParams.get('bbox')).toBe(BBOX);
});
it('FE-API-HORSE-002: no Overpass mirror answering (502) rejects', async () => {
server.use(
http.get('/api/trips/7/tours/horse-routes', () =>
HttpResponse.json({ error: 'Horse route data is unavailable right now' }, { status: 502 })
)
);
await expect(horseRoutesApi.list(7, BBOX)).rejects.toBeTruthy();
});
});
-21
View File
@@ -1,21 +0,0 @@
import apiClient from './client';
/**
* How long the browser waits for GET /api/trips/:tripId/tours/horse-routes.
* The server races the Overpass mirrors under its own deadline (25 s by
* default) and answers 502 when none made it, so this sits just above it, as
* the via ferrata layer's wait does.
*/
const HORSE_ROUTES_TIMEOUT_MS = 30_000;
/**
* HOR-01 GET /api/trips/:tripId/tours/horse-routes: the signposted horse
* routes OpenStreetMap knows inside one map view. The answer comes back
* unparsed; repo/horseRouteRepo.ts checks it against the shared contract.
*/
export const horseRoutesApi = {
list: (tripId: number | string, bbox: string, signal?: AbortSignal): Promise<unknown> =>
apiClient
.get(`/trips/${tripId}/tours/horse-routes`, { params: { bbox }, signal, timeout: HORSE_ROUTES_TIMEOUT_MS })
.then((response) => response.data as unknown),
};
-27
View File
@@ -1,27 +0,0 @@
// FE-API-JOURNEY-MAP-001
//
// JRN-04: both reads pin their path and hand back what the server answered.
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { journeyMapApi } from './journeyMap';
describe('journeyMapApi', () => {
it('FE-API-JOURNEY-MAP-001: reads the layers of a journey and those of a share link', async () => {
const layers = { places: { status: 'withheld' } };
const paths: string[] = [];
server.use(
http.get('/api/journeys/7/map-layers', ({ request }) => {
paths.push(new URL(request.url).pathname);
return HttpResponse.json(layers);
}),
http.get('/api/public/journey/:token/map-layers', ({ request }) => {
paths.push(new URL(request.url).pathname);
return HttpResponse.json(layers);
})
);
expect(await journeyMapApi.layers(7)).toEqual(layers);
expect(await journeyMapApi.publicLayers('a/b')).toEqual(layers);
expect(paths).toEqual(['/api/journeys/7/map-layers', '/api/public/journey/a%2Fb/map-layers']);
});
});
-10
View File
@@ -1,10 +0,0 @@
import type { JourneyMapLayersResponse, JourneyPublicMapLayersResponse } from '@trek/shared';
import apiClient from './client';
/** JRN-04: what a journey map shows beside its entries and photos, for its people and through a share link. */
export const journeyMapApi = {
layers: (journeyId: number, signal?: AbortSignal): Promise<JourneyMapLayersResponse> =>
apiClient.get(`/journeys/${journeyId}/map-layers`, { signal }).then((r) => r.data),
publicLayers: (token: string, signal?: AbortSignal): Promise<JourneyPublicMapLayersResponse> =>
apiClient.get(`/public/journey/${encodeURIComponent(token)}/map-layers`, { signal }).then((r) => r.data),
};
-85
View File
@@ -1,85 +0,0 @@
// FE-API-JOURNEY-RECORDINGS-001
//
// JRN-01: every method pins its verb, its path and its body, and hands back what
// the server answered.
import { http, HttpResponse, type JsonBodyType } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { journeyRecordingsApi } from './journeyRecordings';
const BASE = '/api/journeys/7/recordings';
const HASH = 'a'.repeat(64);
let calls: Array<{ method: string; path: string; body: unknown }> = [];
beforeEach(() => {
calls = [];
});
function record(data: JsonBodyType) {
return async ({ request }: { request: Request }) => {
const text = await request.text();
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
return HttpResponse.json(data);
};
}
describe('journeyRecordingsApi', () => {
it('FE-API-JOURNEY-RECORDINGS-001: lists, previews, keeps and removes the recorded days of a journey', async () => {
server.use(
http.get(BASE, record({ days: [] })),
http.post(`${BASE}/preview`, record({ date: '2026-05-14' })),
http.put(`${BASE}/2026-05-14`, record({ outcome: 'created' })),
http.delete(`${BASE}/2026-05-14`, record({ success: true }))
);
expect(await journeyRecordingsApi.list(7)).toEqual({ days: [] });
expect(
await journeyRecordingsApi.preview(7, { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true })
).toEqual({ date: '2026-05-14' });
expect(await journeyRecordingsApi.save(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH })).toEqual({
outcome: 'created',
});
expect(await journeyRecordingsApi.remove(7, '2026-05-14')).toBeUndefined();
expect(calls).toEqual([
{ method: 'GET', path: BASE, body: null },
{
method: 'POST',
path: `${BASE}/preview`,
body: { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true },
},
{ method: 'PUT', path: `${BASE}/2026-05-14`, body: { utcOffsetMinutes: 120, expectedHash: HASH } },
{ method: 'DELETE', path: `${BASE}/2026-05-14`, body: null },
]);
});
it('FE-API-JOURNEY-RECORDINGS-002: reviews the stays of a day and takes them over (JRN-02)', async () => {
const body = { groups: [{ kind: 'single' as const, stays: [{ suggestionId: 3 }] }], skipSuggestionIds: [4] };
server.use(
http.post(`${BASE}/2026-05-14/stays/review`, record({ date: '2026-05-14', stays: [] })),
http.post(`${BASE}/2026-05-14/stays/import`, record({ entries: [], alreadyTaken: [3], skipped: [] }))
);
expect(await journeyRecordingsApi.reviewDay(7, '2026-05-14')).toEqual({ date: '2026-05-14', stays: [] });
expect(await journeyRecordingsApi.takeOverDay(7, '2026-05-14', body)).toEqual({
entries: [],
alreadyTaken: [3],
skipped: [],
});
expect(calls).toEqual([
{ method: 'POST', path: `${BASE}/2026-05-14/stays/review`, body: {} },
{ method: 'POST', path: `${BASE}/2026-05-14/stays/import`, body },
]);
});
it('FE-API-JOURNEY-RECORDINGS-003: reads how the journey prepares its days (JRN-03)', async () => {
const answer = {
enabled: false,
connection: 'not_connected',
lastAttemptAt: null,
lastSuccessAt: null,
lastError: null,
};
server.use(http.get(`${BASE}/prep`, record(answer)));
expect(await journeyRecordingsApi.prep(7)).toEqual(answer);
expect(calls).toEqual([{ method: 'GET', path: `${BASE}/prep`, body: null }]);
});
});
-44
View File
@@ -1,44 +0,0 @@
import type {
JourneyDayImportRequest,
JourneyDayImportResult,
JourneyDayStays,
JourneyRecordedDaysResponse,
JourneyRecordingPrep,
JourneyRecordingPreview,
JourneyRecordingPreviewRequest,
JourneyRecordingSaveRequest,
JourneyRecordingSaveResult,
} from '@trek/shared';
import apiClient from './client';
import { DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich';
const base = (journeyId: number) => `/journeys/${journeyId}/recordings`;
/** The preview and the save ask Dawarich first, so they get its longer wait. */
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS };
/**
* JRN-01: the recorded days a journey keeps, previewing one, keeping it and dropping it.
* JRN-02: the stays of a day reviewed and taken over as drafts.
* JRN-03: how the journey prepares its days for the caller.
*/
export const journeyRecordingsApi = {
list: (journeyId: number, signal?: AbortSignal): Promise<JourneyRecordedDaysResponse> =>
apiClient.get(base(journeyId), { signal }).then((r) => r.data),
prep: (journeyId: number, signal?: AbortSignal): Promise<JourneyRecordingPrep> =>
apiClient.get(`${base(journeyId)}/prep`, { signal }).then((r) => r.data),
preview: (
journeyId: number,
body: JourneyRecordingPreviewRequest,
signal?: AbortSignal
): Promise<JourneyRecordingPreview> =>
apiClient.post(`${base(journeyId)}/preview`, body, { ...upstream, signal }).then((r) => r.data),
save: (journeyId: number, date: string, body: JourneyRecordingSaveRequest): Promise<JourneyRecordingSaveResult> =>
apiClient.put(`${base(journeyId)}/${date}`, body, upstream).then((r) => r.data),
remove: (journeyId: number, date: string): Promise<void> =>
apiClient.delete(`${base(journeyId)}/${date}`).then(() => undefined),
reviewDay: (journeyId: number, date: string, signal?: AbortSignal): Promise<JourneyDayStays> =>
apiClient.post(`${base(journeyId)}/${date}/stays/review`, {}, { ...upstream, signal }).then((r) => r.data),
takeOverDay: (journeyId: number, date: string, body: JourneyDayImportRequest): Promise<JourneyDayImportResult> =>
apiClient.post(`${base(journeyId)}/${date}/stays/import`, body).then((r) => r.data),
};
-13
View File
@@ -1,13 +0,0 @@
import { paddleRegionLoadResponseSchema, type PaddleRegionLoadRequest, type PaddleRegionLoadState } from '@trek/shared';
import apiClient from './client';
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
const GEOFABRIK = { timeout: 30_000 };
/** PAD-11C: the water data around places no paddle region covers yet, loaded or asked for by the server. */
export const paddleRegionLoadApi = {
load: (request: PaddleRegionLoadRequest, signal?: AbortSignal): Promise<PaddleRegionLoadState[]> =>
apiClient
.post('/paddle/regions/load', request, { signal, ...GEOFABRIK })
.then((r) => paddleRegionLoadResponseSchema.parse(r.data).points),
};
+2 -11
View File
@@ -2,19 +2,17 @@ import {
paddleRegionCatalogResponseSchema,
paddleRegionListResponseSchema,
paddleRegionSchema,
paddleRegionSettingsSchema,
successResponseSchema,
type PaddleRegionRequest,
type PaddleRegionSettings,
} from '@trek/shared';
import apiClient from './client';
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
const GEOFABRIK = { timeout: 30_000 };
/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */
/** PAD-05A, the admin side: the regions TREK reads paddle data for. */
export const paddleRegionsApi = {
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
catalog: (q: string, signal?: AbortSignal) =>
apiClient
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
@@ -31,11 +29,4 @@ export const paddleRegionsApi = {
apiClient
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)),
decline: (id: string) =>
apiClient
.post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)),
settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)),
saveSettings: (settings: PaddleRegionSettings) =>
apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)),
};
-10
View File
@@ -1,10 +0,0 @@
import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared';
import apiClient from './client';
/** PAD-11: legs of a paddle tour planned on the water by the server's router. */
export const paddleRouteApi = {
plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise<PaddleRouteResponse> =>
apiClient
.post(`/trips/${tripId}/paddle/route`, request, { signal })
.then((r) => paddleRouteResponseSchema.parse(r.data)),
};
-8
View File
@@ -1,8 +0,0 @@
import { skiRouteResponseSchema, type SkiRouteInput, type SkiRouteResponse } from '@trek/shared';
import apiClient from './client';
/** The piste router: a ski tour's legs planned on the piste data the server keeps, held to the contract. */
export const skiRouteApi = {
plan: (tripId: number | string, request: SkiRouteInput, signal?: AbortSignal): Promise<SkiRouteResponse> =>
apiClient.post(`/trips/${tripId}/ski/route`, request, { signal }).then((r) => skiRouteResponseSchema.parse(r.data)),
};
-14
View File
@@ -1,14 +0,0 @@
import type { TourActivityAssignRequest, TourDetailResponse } from '@trek/shared';
import apiClient from './client';
/** ACT-01: the recorded tracks of a tour, assigned and taken off; both answer the tour's detail. */
export const tourActivitiesApi = {
assign: (
tripId: number | string,
placeId: number | string,
body: TourActivityAssignRequest
): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}/activities`, body).then((r) => r.data),
unassign: (tripId: number | string, placeId: number | string, activityId: number): Promise<TourDetailResponse> =>
apiClient.delete(`/trips/${tripId}/tours/${placeId}/activities/${activityId}`).then((r) => r.data),
};
-10
View File
@@ -1,10 +0,0 @@
import type { TourActivityExclusionRequest, TourActivityStatsResponse } from '@trek/shared';
import apiClient from './client';
/** STA-02: the user's own tour activities across their trips, and leaving one out of them; both answer the sums. */
export const tourActivityStatsApi = {
get: (signal?: AbortSignal): Promise<TourActivityStatsResponse> =>
apiClient.get('/tours/activity-stats', { signal }).then((r) => r.data),
setExcluded: (body: TourActivityExclusionRequest): Promise<TourActivityStatsResponse> =>
apiClient.put('/tours/activity-stats/exclusions', body).then((r) => r.data),
};
-18
View File
@@ -1,18 +0,0 @@
import type { AdminFeatureToggleResponse } from '@trek/shared';
import apiClient from './client';
/** An admin's switch for a map or a service the Tours planner offers on its operator's terms. */
function tourBaseMapAdminApi(path: string) {
return {
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get(path).then((r) => r.data),
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
apiClient.put(path, { enabled }).then((r) => r.data),
};
}
/** MAP-01: OpenHikingMap for a hike. */
export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map');
/** BIK-06: CyclOSM for a bike tour. */
export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map');
/** SKI-02: the piste router, which plans ski tours on the Places host's piste data. */
export const skiRoutingAdminApi = tourBaseMapAdminApi('/admin/tours-ski-routing');
+2 -5
View File
@@ -1,7 +1,7 @@
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
import { postMultipart } from './client';
/** A GPX, KML or KMZ file and the fields beside it, as the Tours import routes read a multipart body. */
/** A GPX file and the fields beside it, as the Tours import routes read a multipart body. */
function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
const fd = new FormData();
fd.append('file', file);
@@ -9,10 +9,7 @@ function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
return fd;
}
/**
* The Tours file import: a GPX, KML or KMZ file (IO-02) read for its preview
* first (PAD-02), then imported as the type chosen.
*/
/** The Tours GPX import: a file read for its preview first (PAD-02), then imported as the type chosen. */
export const tourGpxApi = {
/** What the file holds, read by the import's own parser; nothing is stored. */
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
-33
View File
@@ -1,33 +0,0 @@
import apiClient from './client';
/**
* The file name a download's Content-Disposition gives: the UTF-8 `filename*`
* first, since it keeps the tour's own script, the plain `filename` after.
*/
export function attachmentFilename(header: unknown): string | null {
if (typeof header !== 'string') return null;
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1];
if (encoded) {
try {
return decodeURIComponent(encoded.trim());
} catch {
// A malformed escape falls through to the plain name.
}
}
return /filename="([^"]+)"/i.exec(header)?.[1] ?? null;
}
/** PR1-06A: one saved tour as the GPX file the server writes. */
export const tourGpxExportApi = {
download: async (
tripId: number | string,
placeId: number,
signal?: AbortSignal
): Promise<{ blob: Blob; filename: string | null }> => {
const response = await apiClient.get<Blob>(`/trips/${tripId}/tours/${placeId}/export.gpx`, {
responseType: 'blob',
signal,
});
return { blob: response.data, filename: attachmentFilename(response.headers['content-disposition']) };
},
};
-18
View File
@@ -1,18 +0,0 @@
import type {
TourCreateResponse,
TourLibraryDetailResponse,
TourLibraryQuery,
TourLibraryResponse,
TourTakeOverRequest,
} from '@trek/shared';
import apiClient from './client';
/** LIB-02: the tours of the user's other trips, one's preview, and taking one over into a trip. */
export const tourLibraryApi = {
list: (query: TourLibraryQuery, signal?: AbortSignal): Promise<TourLibraryResponse> =>
apiClient.get('/tours/library', { params: query, signal }).then((r) => r.data),
detail: (placeId: number, signal?: AbortSignal): Promise<TourLibraryDetailResponse> =>
apiClient.get(`/tours/library/${placeId}`, { signal }).then((r) => r.data),
takeOver: (tripId: number | string, body: TourTakeOverRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/take-over`, body).then((r) => r.data),
};
-20
View File
@@ -1,20 +0,0 @@
import type { TourDetailResponse, TourPackingLinkRequest, TourPackingLinkResponse } from '@trek/shared';
import apiClient from './client';
/**
* Where a tour's packing link lives; a write queued offline replays to the
* same path, `{id}` standing for a tour that has not synced yet.
*/
export function tourPackingLinkPath(tripId: number | string, placeId: number | string): string {
return `/trips/${tripId}/tours/${placeId}/packing-link`;
}
/** P2D-XD-01: the packing list, or the category of it, a tour points at. */
export const tourPackingLinkApi = {
read: (tripId: number | string, placeId: number, signal?: AbortSignal): Promise<TourPackingLinkResponse> =>
apiClient.get(tourPackingLinkPath(tripId, placeId), { signal }).then((r) => r.data),
link: (tripId: number | string, placeId: number, body: TourPackingLinkRequest): Promise<TourDetailResponse> =>
apiClient.put(tourPackingLinkPath(tripId, placeId), body).then((r) => r.data),
unlink: (tripId: number | string, placeId: number): Promise<TourDetailResponse> =>
apiClient.delete(tourPackingLinkPath(tripId, placeId)).then((r) => r.data),
};
-17
View File
@@ -1,17 +0,0 @@
import type { TourPoiSearchRequest, TourPoiSearchResponse } from '@trek/shared';
import apiClient from './client';
/**
* PLN-02: places along a tour. A search runs several stretches against the map
* search on the server, each allowed its own timeout there, so it may take
* longer than the shared default: up to three rounds of stretches, each asking
* the Overpass mirrors one after another for about twenty seconds at most.
*/
const POI_SEARCH_TIMEOUT_MS = 75_000;
export const tourPoiSearchApi = {
search: (tripId: number | string, body: TourPoiSearchRequest, signal?: AbortSignal): Promise<TourPoiSearchResponse> =>
apiClient
.post(`/trips/${tripId}/tours/poi-search`, body, { signal, timeout: POI_SEARCH_TIMEOUT_MS })
.then((r) => r.data),
};
-11
View File
@@ -1,11 +0,0 @@
import type { TourDetailResponse, TourSkiClassificationRequest } from '@trek/shared';
import apiClient from './client';
/** SKI-02: a ski track classified again, its discipline and cross-country technique. */
export const tourSkiApi = {
classify: (
tripId: number | string,
placeId: number | string,
body: TourSkiClassificationRequest
): Promise<TourDetailResponse> => apiClient.put(`/trips/${tripId}/tours/${placeId}/ski`, body).then((r) => r.data),
};
-20
View File
@@ -1,20 +0,0 @@
import type { TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared';
import apiClient from './client';
/**
* BIK-05: the trail grades of the ways a mountain bike tour runs on. The
* server asks Overpass in parts, one after another, so a long tour may take
* longer than the shared default.
*/
const TRAIL_GRADE_TIMEOUT_MS = 60_000;
export const tourTrailGradeApi = {
grades: (
tripId: number | string,
body: TourTrailGradeRequest,
signal?: AbortSignal
): Promise<TourTrailGradeResponse> =>
apiClient
.post(`/trips/${tripId}/tours/trail-grades`, body, { signal, timeout: TRAIL_GRADE_TIMEOUT_MS })
.then((r) => r.data),
};
-20
View File
@@ -1,20 +0,0 @@
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
import apiClient from './client';
/** Where one station of a walk is marked for one day; a write queued offline replays to the same path. */
export function tourVisitPath(tripId: number | string, assignmentId: number, stationKey: string): string {
return `/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`;
}
/** CW-03: the stations of a trip's city walks visited or skipped on their days. */
export const tourVisitsApi = {
list: (tripId: number | string): Promise<TourVisitListResponse> =>
apiClient.get(`/trips/${tripId}/tour-visits`).then((r) => r.data),
update: (
tripId: number | string,
assignmentId: number,
stationKey: string,
body: TourVisitUpdateRequest
): Promise<TourVisitListResponse> =>
apiClient.put(tourVisitPath(tripId, assignmentId, stationKey), body).then((r) => r.data),
};
-20
View File
@@ -1,20 +0,0 @@
import type { WalkImportPreviewRequest, WalkImportPreviewResponse } from '@trek/shared';
import apiClient from './client';
/**
* CW-07: a Google Maps walking route read into its stops. The server may
* follow a short link a few hops within its own deadline, so the call waits
* a little longer than that before it gives up.
*/
const WALK_IMPORT_TIMEOUT_MS = 20_000;
export const tourWalkImportApi = {
preview: (
tripId: number | string,
body: WalkImportPreviewRequest,
signal?: AbortSignal
): Promise<WalkImportPreviewResponse> =>
apiClient
.post(`/trips/${tripId}/tours/import/google-maps/preview`, body, { signal, timeout: WALK_IMPORT_TIMEOUT_MS })
.then((r) => r.data),
};
-31
View File
@@ -1,31 +0,0 @@
import type {
TripJournalCandidatesResponse,
TripJournalLinkMemberRequest,
TripJournalLinkResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
* and who it is open to. In its own file rather than in the `client.ts` god
* module; repo/tripJournalLinkRepo.ts is the one caller.
*/
export const tripJournalApi = {
get: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
link: (
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> =>
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
/** The journals this person can open; the dialog offers the ones they own. */
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
apiClient.get('/journeys').then((r) => r.data),
};
-150
View File
@@ -1,150 +0,0 @@
/**
* 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
@@ -1,111 +0,0 @@
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;
}
-9
View File
@@ -1,7 +1,6 @@
import type {
AdminFeatureToggleResponse,
TourSourceCheckResponse,
TourSourceHintResponse,
WandererConnection,
WandererImportPreview,
WandererImportRequest,
@@ -12,7 +11,6 @@ import type {
WandererSettingsRequest,
WandererTestRequest,
WandererTestResponse,
WandererWatch,
} from '@trek/shared';
import apiClient from './client';
@@ -33,10 +31,6 @@ export const wandererApi = {
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
/** WAN-02: the optional background watch of the connection, with the open hints. */
getWatch: (): Promise<WandererWatch> => apiClient.get('/integrations/wanderer/watch').then((r) => r.data),
setWatch: (enabled: boolean): Promise<WandererWatch> =>
apiClient.put('/integrations/wanderer/watch', { enabled }).then((r) => r.data),
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
@@ -49,9 +43,6 @@ export const wandererApi = {
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
/** WAN-02: the caller's own background hint on a snapshot; the server only reads its own rows. */
sourceHint: (tripId: number, placeId: number, signal?: AbortSignal): Promise<TourSourceHintResponse> =>
apiClient.get(`/trips/${tripId}/tours/wanderer/sources/${placeId}/hint`, { signal }).then((r) => r.data),
};
/** The admin's switch for wanderer under the Tours addon. */
-9
View File
@@ -19,8 +19,6 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
'tours:changed',
// CW-03: store/tourVisitStore (watchTourVisits) keeps the visits store current.
'tours:visits',
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created',
'collab:note:updated',
@@ -81,9 +79,6 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// Capture times landed after a photo import (#1587); the same listener reloads
// the journey so the gallery re-sorts.
'journey:photos:updated',
// A day's recorded route was taken over or removed (JRN-01):
// components/Journey/useJourneyRecordings reads the kept days again.
'journey:recording:updated',
// Studio book — components/Studio/useBookStore (its own listener: a client
// with nothing outstanding takes the new version, one with unsaved edits
// deliberately does not and conflicts on its next save instead)
@@ -98,10 +93,6 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// the bag ping: what a run changed is per-binding server state, and the
// files it moved arrive as file:* events.
'docsync:changed',
// Linked journal: components/Trips/useTripJournalLink asks again which journal
// the trip shows and who it is open to. Content-free, because that answer is
// per person (a member left off the link must not learn its title).
'trip:journal:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
+278 -53
View File
@@ -1,6 +1,6 @@
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { delay, http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
@@ -24,20 +24,23 @@ function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
}
function llmAddon(config: Record<string, unknown> = {}, enabled = true) {
function llmAddon(config: Record<string, unknown> = {}) {
return buildAddon({
id: 'llm_parsing',
name: 'AI Parsing',
description: 'Extract bookings from files',
icon: 'Sparkles',
type: 'integration',
enabled,
enabled: true,
config,
});
}
function aiAddon(id: string, name: string, enabled: boolean) {
return buildAddon({ id, name, icon: id === 'mcp' ? 'Terminal' : 'Sparkles', type: 'integration', enabled });
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 })) });
});
}
/** Every switch is labelled with the thing it controls, top-level tile or shelf
@@ -75,11 +78,7 @@ beforeEach(() => {
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false })),
// The Tours switches load beside the list; unanswered, each would toast the same error as a failed list.
http.get('/api/admin/tours-hiking-map', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/tours-cycle-map', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/tours-ski-routing', () => HttpResponse.json({ enabled: false }))
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
);
});
@@ -573,55 +572,286 @@ describe('AddonManager', () => {
expect(links.getAttribute('title')).not.toBe('No shared links yet');
});
it('FE-ADMIN-ADDON-020: the AI addons head the integration column as one tile with a switch each', async () => {
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;
server.use(
addonsRoute([
aiAddon('mcp', 'MCP', false),
llmAddon({}, false),
aiAddon('ai_assistant', 'AI Assistant', false),
buildAddon({ id: 'airtrail', name: 'AirTrail', type: 'integration', icon: 'Plane' }),
]),
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: [] });
}),
);
render(<AddonManager />);
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);
// 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);
});
it('FE-ADMIN-ADDON-021: with parsing on, the tile offers the settings dialog, which closes again', async () => {
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
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();
});
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({}), 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 })),
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(<AddonManager />);
render(<><ToastContainer /><AddonManager /></>);
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());
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(
@@ -667,10 +897,6 @@ 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);
@@ -691,7 +917,6 @@ describe('AddonManager', () => {
</>
);
await unfoldShelves();
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
+158 -31
View File
@@ -1,7 +1,9 @@
import type { ComponentType } from 'react'
import { useId, 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, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
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 EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
@@ -10,10 +12,11 @@ import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel'
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 { useAddonManager } from './addons/useAddonManager'
import AiAddonTile from './addons/AiAddonTile'
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
import WandererMark from '../Tours/wanderer/WandererMark'
@@ -71,10 +74,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const { t } = useTranslation()
const dark = useIsDark()
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
if (loading) {
@@ -151,29 +153,21 @@ 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
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
}
@@ -202,9 +196,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{ key: 'trip', addons: tripAddons },
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0))
].filter(g => g.addons.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 + aiAddons.length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
const subtitle = (
<span className="inline-flex flex-wrap items-center gap-1">
@@ -235,12 +229,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const meta = TYPE_META[g.key]
return (
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
<div className="divide-y divide-edge-faint">
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
{g.addons.map(tile)}
</div>
<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>
)
})}
@@ -279,3 +269,140 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
</div>
)
}
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
* overrides per-user config (see server llmConfig.ts). The API key is masked on
* read; an unchanged mask is treated as a no-op by the server. For the local
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const { t } = useTranslation()
const {
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
} = useLlmParsingConfig(addon)
const fieldId = useId()
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
/* Lives in the tile's shelf like the collab toggles, so it is one column of
eyebrow fields: the band this used to be had a whole page width. */
return (
<li className={`flex flex-col gap-3 p-3 ${saving ? 'opacity-60' : ''}`}>
<SettingsHint>
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
</SettingsHint>
<EditorField label="Provider">
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</EditorField>
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
</EditorField>
<EditorField
label="API key"
htmlFor={`${fieldId}-key`}
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
>
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</EditorField>
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</EditorField>
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
<Segmented<LlmVision>
label={t('settings.aiParsing.multimodal')}
value={vision}
onChange={v => setVision(asLlmVision(v))}
options={visionOptions}
fill
/>
</EditorField>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
<div className="flex items-center gap-2">
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
<button type="button" onClick={loadModels} disabled={loadingModels}
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
style={fs(11.5, 'body')}>
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button type="button"
key={name}
title={name}
onClick={() => setModel(name)}
aria-pressed={model === name}
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
style={fs(11.5)}
>
{name}
</button>
))}
</div>
)}
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className="min-w-0" title={m.note}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
{installedHere ? (
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
{isPulling && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
)
})}
</div>
</div>
)}
<div className="flex justify-end">
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</li>
)
}
@@ -1,95 +0,0 @@
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>
);
}
@@ -1,339 +0,0 @@
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-020: the AI settings dialog behind the AI tile.
import userEvent from '@testing-library/user-event';
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
import { delay, http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
import { useAssistantStore } from '../../../store/assistantStore';
import { ToastContainer } from '../../shared/Toast';
import type { Addon } from './addonModel';
const repo = vi.hoisted(() => ({
status: vi.fn(),
adminSettings: vi.fn(),
saveAdminSettings: vi.fn(),
adminConnection: vi.fn(),
saveAdminConnection: vi.fn(),
removeAdminConnection: vi.fn(),
adminModels: vi.fn(),
}));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
const LIVE: AiAdminConnectionResponse = {
connection: {
scope: 'instance',
kind: 'openai',
model: 'gpt-5.5',
hint: '…9f3c',
status: 'ok',
host: 'api.openai.com',
baseUrl: null,
},
prefill: null,
};
function addon(id: string, enabled: boolean, config?: Record<string, unknown>): Addon {
return { id, name: id, description: '', icon: 'Sparkles', type: 'integration', enabled, config } as Addon;
}
const ASSISTANT = addon('ai_assistant', true);
const parsing = (config: Record<string, unknown> = {}) => addon('llm_parsing', true, config);
function renderDialog(addons: Addon[] = [ASSISTANT]) {
const onClose = vi.fn();
render(
<>
<ToastContainer />
<AiSettingsDialog addons={addons} onClose={onClose} />
</>
);
return { onClose };
}
/** The dialog opened on AI parsing's tab. */
async function renderParsing(config: Record<string, unknown> = {}) {
const user = userEvent.setup();
renderDialog([ASSISTANT, parsing(config)]);
await user.click(await screen.findByRole('button', { name: 'AI Parsing' }));
return user;
}
function modelsRoute(names: string[], seen?: (string | null)[]) {
return http.get('/api/admin/llm/local/models', ({ request }) => {
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
});
}
function saveRoute(bodies: unknown[], fail = false) {
return http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return fail ? HttpResponse.error() : HttpResponse.json({ success: true });
});
}
beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
repo.status.mockRejectedValue(new Error('not needed'));
repo.adminSettings.mockResolvedValue(SETTINGS);
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
repo.adminConnection.mockResolvedValue({ connection: null, prefill: null });
repo.removeAdminConnection.mockResolvedValue(undefined);
repo.adminModels.mockResolvedValue([{ id: 'gpt-5.5', label: 'GPT-5.5', supportsTools: true }]);
});
describe('the connection and the assistant', () => {
it('FE-COMP-AI-ADMIN-001: shows who pays and the setup for everyone, the limits on the assistant tab', async () => {
const user = userEvent.setup();
renderDialog();
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
// The admin may share their own ChatGPT plan with everyone, so its tile is offered too.
expect(screen.getByRole('button', { name: /Sign in with ChatGPT/ })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
expect(screen.getByText('Keep chats')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
const user = userEvent.setup();
renderDialog();
await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' }));
await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument());
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => {
const user = userEvent.setup();
repo.adminConnection.mockResolvedValue(LIVE);
renderDialog();
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('…9f3c')).toBeInTheDocument();
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Change' }));
expect(screen.getByText('What would you like to chat with?')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.getByText('OpenAI')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-004: removing the connection asks first', async () => {
const user = userEvent.setup();
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue({ connection: null, prefill: null });
renderDialog();
await user.click(await screen.findByRole('button', { name: 'Remove connection' }));
expect(screen.getByText('Disconnect OpenAI? Your chats stay.')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Remove connection' });
await user.click(buttons[buttons.length - 1]);
expect(await screen.findByText('No connection yet.')).toBeInTheDocument();
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
});
it('FE-COMP-AI-ADMIN-005: a tab shows only while its addon is on, and Close closes', async () => {
const user = userEvent.setup();
const { onClose } = renderDialog([addon('ai_assistant', false), parsing()]);
expect(await screen.findByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'AI Assistant' })).not.toBeInTheDocument();
const closes = screen.getAllByRole('button', { name: 'Close' });
await user.click(closes[closes.length - 1]);
expect(onClose).toHaveBeenCalled();
});
});
describe('AI parsing', () => {
it('FE-COMP-AI-ADMIN-010: without its own connection parsing reads with the shared one, and can be given its own', async () => {
repo.adminConnection.mockResolvedValue(LIVE);
server.use(modelsRoute([]));
const user = await renderParsing({ vision: 'auto' });
expect(await screen.findByText('Shared')).toBeInTheDocument();
expect(screen.getByText('gpt-5.5')).toBeInTheDocument();
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Use a different one' }));
expect(screen.getByLabelText('Model')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-011: with no connection anywhere it says so and leads to the connection tab', async () => {
const user = await renderParsing();
expect(
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
).toBeInTheDocument();
// The link in the hint, not the tab of the same name above it.
const link = screen.getAllByRole('button', { name: 'Connection' }).find((b) => !b.hasAttribute('aria-pressed'));
await user.click(link as HTMLElement);
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-012: an own connection is shown as such and goes back to the shared one, keeping the vision setting', async () => {
const bodies: unknown[] = [];
repo.adminConnection.mockResolvedValue(LIVE);
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'anthropic', model: 'claude-x', apiKey: '••••••••', vision: 'on' });
expect(await screen.findByText('Own connection', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByText('claude-x')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Use the shared connection' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({ config: { vision: 'on' } });
expect(await screen.findByText('Shared')).toBeInTheDocument();
});
it('FE-COMP-AI-ADMIN-013: the local provider lists installed models and a chip fills the model field', async () => {
const urls: (string | null)[] = [];
server.use(modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
const user = await renderParsing({ provider: 'local', model: 'old' });
await screen.findByText('Installed on the server');
await user.click(await screen.findByRole('button', { name: 'llama3:8b' }));
expect(urls[0]).toBe('http://localhost:11434/v1');
expect(screen.getByLabelText('Model')).toHaveValue('llama3:8b');
// qwen3.5:4b is installed already, so the recommended row offers Use instead of Pull.
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
it("FE-COMP-AI-ADMIN-014: an unreachable Ollama shows the server's reason and Refresh retries", async () => {
let calls = 0;
server.use(
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
})
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('down');
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet. Pull one below.');
expect(calls).toBe(2);
});
it('FE-COMP-AI-ADMIN-015: switching providers swaps the address placeholder and the Ollama block, and drops a stale address', async () => {
const bodies: unknown[] = [];
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'local', model: 'm', baseUrl: 'http://ollama.lan:11434/v1' });
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toHaveValue('');
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: 'Anthropic' }));
// A gateway can stand in for Anthropic too, so the field stays (#2655).
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
await user.clear(screen.getByLabelText('Model'));
await user.type(screen.getByLabelText('Model'), ' claude-haiku ');
await user.type(screen.getByLabelText('API key'), 'sk-ant-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('AI settings saved');
// The stale local address must not ride along to Anthropic, and the model is trimmed.
expect(bodies[0]).toEqual({
config: { provider: 'anthropic', model: 'claude-haiku', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
});
});
it('FE-COMP-AI-ADMIN-016: a hand-typed address is asked on blur, a cloud provider asks nothing', async () => {
const urls: (string | null)[] = [];
server.use(modelsRoute([], urls));
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('Installed on the server');
await user.type(screen.getByLabelText('Base URL'), 'http://ollama.lan:11434/v1');
await user.tab();
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
const before = urls.length;
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
await user.type(screen.getByLabelText('Base URL'), 'https://proxy.local/v1');
await user.tab();
expect(urls).toHaveLength(before);
});
it('FE-COMP-AI-ADMIN-017: a pull streams progress and then selects the model', async () => {
let pulled: unknown = null;
let modelCalls = 0;
server.use(
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
await delay(150);
return new HttpResponse(
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } }
);
})
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('No models installed yet. Pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
expect(await screen.findAllByText('Pulling…')).not.toHaveLength(0);
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
it('FE-COMP-AI-ADMIN-018: an error frame or a refused pull is reported and the button comes back', async () => {
server.use(
modelsRoute([]),
http.post(
'/api/admin/llm/local/pull',
() =>
new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', {
headers: { 'Content-Type': 'application/x-ndjson' },
})
)
);
const user = await renderParsing({ provider: 'local', model: 'm' });
await screen.findByText('No models installed yet. Pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('manifest not found');
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
});
it('FE-COMP-AI-ADMIN-019: saving keeps a masked key and reports both outcomes', async () => {
const bodies: unknown[] = [];
server.use(modelsRoute([]), saveRoute(bodies));
const user = await renderParsing({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••' });
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
});
server.use(saveRoute(bodies, true));
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Could not save AI settings');
});
it('FE-COMP-AI-ADMIN-020: whether the model reads images saves at once, with the connection as saved', async () => {
const bodies: unknown[] = [];
server.use(saveRoute(bodies));
const user = await renderParsing({
provider: 'openai',
model: 'gpt-4.1-mini',
baseUrl: '',
apiKey: 'sk',
vision: 'off',
});
expect(await screen.findByRole('button', { name: 'No' })).toBeInTheDocument();
expect(
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Yes' }));
await screen.findByText('AI settings saved');
expect(bodies[0]).toEqual({
config: { provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'on' },
});
});
});
@@ -1,270 +0,0 @@
import { Check, Loader2, Sparkles } from 'lucide-react';
import React, { useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import ConfirmDialog from '../../shared/ConfirmDialog';
import CustomSelect from '../../shared/CustomSelect';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
import { NumericInput } from '../../shared/NumericInput';
import { EditorField, INPUT, Segmented } from '../../shared/dialogParts';
import ParsingSettings from './ParsingSettings';
import type { Addon } from './addonModel';
import { POLICY_CHOICES, useAiAdminConfig } from './useAiAdminConfig';
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint';
/** Who pays, as three rows to pick one from: the labels are too long for a segmented track in a shelf. */
function PolicyChoice({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<span className={EYEBROW} style={fs(9.5)}>
{t('assistant.admin.policy')}
</span>
<div role="radiogroup" aria-label={t('assistant.admin.policy')} className="flex flex-col gap-1.5">
{POLICY_CHOICES.map((choice) => {
const on = A.policy === choice.value;
return (
<button
key={choice.value}
type="button"
role="radio"
aria-checked={on}
onClick={() => A.setPolicy(choice.value)}
className={`flex items-center gap-2 rounded-[10px] border px-3 py-2 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
>
<span
className={`min-w-0 flex-1 font-medium ${on ? 'text-content' : 'text-content-muted'}`}
style={fs(12.5, 'body')}
>
{t(choice.labelKey)}
</span>
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-content" />}
</button>
);
})}
</div>
{A.policyHintKey && <SettingsHint>{t(A.policyHintKey)}</SettingsHint>}
</div>
);
}
/** The connection everyone runs on, once set up: who answers, its model, and replacing or removing it. */
function LiveConnection({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const { live, provider, models, busy, setModel, replace, askRemove } = A.instance;
if (!live) return null;
const status = CONNECTION_STATUS[live.status];
return (
<div
className={`flex flex-col gap-3 rounded-[12px] border border-edge-faint bg-surface-card p-3 ${busy ? 'opacity-60' : ''}`}
>
<div className="flex items-center gap-2.5">
<ProviderMark kind={live.kind} />
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
{provider}
</p>
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
{live.hint ?? live.host}
</p>
</div>
<StatusPill tone={status.tone}>{t(status.labelKey)}</StatusPill>
</div>
{live.status === 'ok' && (
<EditorField label={t('assistant.connect.model')} hint={t('assistant.connect.modelHint')}>
<CustomSelect
ariaLabel={t('assistant.connect.model')}
value={live.model}
onChange={(v) => setModel(String(v))}
options={models}
size="sm"
disabled={busy}
searchable={models.length > 8}
/>
</EditorField>
)}
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
<button type="button" onClick={replace} disabled={busy} className={SETTINGS_BUTTON}>
{t('common.change')}
</button>
<button type="button" onClick={askRemove} disabled={busy} className={SETTINGS_BUTTON_DANGER}>
{t('assistant.admin.connectionRemove')}
</button>
</div>
</div>
);
}
function InstanceConnection({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const { live, connection, replacing, cancelReplace, onConnected } = A.instance;
return (
<div className="flex flex-col gap-2.5">
<span className={EYEBROW} style={fs(9.5)}>
{t('assistant.admin.connection')}
</span>
{!replacing && <LiveConnection A={A} />}
{!live && <SettingsHint>{t('assistant.admin.connectionNone')}</SettingsHint>}
{A.showPanel && (
<AiConnectPanel scope="instance" onConnected={onConnected} prefill={connection?.prefill ?? null} />
)}
{replacing && (
<button
type="button"
onClick={cancelReplace}
className={`${SETTINGS_BUTTON} self-start`}
style={fs(12.5, 'body')}
>
{t('common.cancel')}
</button>
)}
</div>
);
}
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
function AssistantLimits({ A }: { A: AiAdmin }) {
const { t } = useTranslation();
const fieldId = useId();
return (
<div className="flex flex-col gap-4">
{A.instancePays && (
<EditorField
label={t('assistant.admin.dailyLimit')}
htmlFor={`${fieldId}-daily`}
hint={t('assistant.admin.dailyLimitHint')}
>
<NumericInput
id={`${fieldId}-daily`}
mode="integer"
className={`${INPUT} tabular-nums`}
value={A.dailyDraft}
onValueChange={A.setDailyDraft}
onBlur={A.commitDailyLimit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
</EditorField>
)}
<EditorField label={t('assistant.admin.retention')}>
<CustomSelect
ariaLabel={t('assistant.admin.retention')}
value={A.retention}
onChange={(v) => A.setRetention(String(v))}
options={A.retentionOptions}
size="sm"
/>
</EditorField>
</div>
);
}
type AiTab = 'connection' | 'assistant' | 'parsing';
/**
* The AI settings behind the AI tile's button, in one dialog so the addon page
* stays short: the connection the assistant and AI parsing share (and who pays
* for it), the assistant's limits, and how bookings are read. A tab shows only
* while its addon is on. Each change saves at once.
*/
export default function AiSettingsDialog({
addons,
onClose,
}: {
addons: Addon[];
onClose: () => void;
}): React.ReactElement {
const { t } = useTranslation();
const labelId = useId();
const A = useAiAdminConfig();
const assistantOn = addons.some((a) => a.id === 'ai_assistant' && a.enabled);
const parsing = addons.find((a) => a.id === 'llm_parsing' && a.enabled) ?? null;
const tabs: { value: AiTab; label: string }[] = [
{ value: 'connection', label: t('admin.addons.ai.tab.connection') },
...(assistantOn ? [{ value: 'assistant' as const, label: t('admin.addons.catalog.ai_assistant.name') }] : []),
...(parsing ? [{ value: 'parsing' as const, label: t('admin.addons.catalog.llm_parsing.name') }] : []),
];
const [tab, setTab] = useState<AiTab>('connection');
const shown = tabs.some((x) => x.value === tab) ? tab : 'connection';
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="detail"
align="top"
blocked={A.instance.confirmingRemove}
header={
<DialogHeader
tile={
<DialogTile>
<Sparkles size={20} strokeWidth={2} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
eyebrow={t('admin.addons.ai.name')}
title={t('admin.addons.ai.setup')}
sub={t('admin.addons.ai.dialogSub')}
subWraps
onClose={onClose}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={onClose}>
{t('common.close')}
</DialogButton>
</DialogFooter>
}
>
{tabs.length > 1 && (
<Segmented<AiTab> label={t('admin.addons.ai.setup')} value={shown} onChange={setTab} options={tabs} fill />
)}
{!A.loaded ? (
<div className="grid place-items-center py-10">
<Loader2 size={20} className="animate-spin text-content-faint" />
</div>
) : (
<>
{shown === 'connection' && (
<div className="flex flex-col gap-4">
<PolicyChoice A={A} />
{A.instancePays && <InstanceConnection A={A} />}
</div>
)}
{shown === 'assistant' && <AssistantLimits A={A} />}
{shown === 'parsing' && parsing && (
<ParsingSettings addon={parsing} A={A} onOpenConnection={() => setTab('connection')} />
)}
</>
)}
<ConfirmDialog
isOpen={A.instance.confirmingRemove}
onClose={A.instance.cancelRemove}
onConfirm={A.instance.remove}
title={t('assistant.admin.connectionRemove')}
message={t('assistant.connect.disconnectConfirm', { provider: A.instance.provider })}
confirmLabel={t('assistant.admin.connectionRemove')}
danger
/>
</DialogShell>
);
}
@@ -1,348 +0,0 @@
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>
);
}
@@ -1,32 +0,0 @@
import { Cable, type LucideIcon, MessagesSquare, ScanText } from 'lucide-react';
import type { Addon } from './addonModel';
/**
* The three addons that talk to a language model, shown as one AI tile in the
* integration column: the assistant in the trip, AI parsing (booking import)
* and MCP (TREK inside other AI apps). Their ids, rows and settings stay their
* own; only the page groups them. Assistant and parsing share one connection.
*/
const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const;
export type AiAddonId = (typeof AI_ADDON_IDS)[number];
/** Each one's glyph, keyed by id: the icon column of an existing install still holds what it was seeded with. */
export const AI_ADDON_ICONS: Record<AiAddonId, LucideIcon> = {
ai_assistant: MessagesSquare,
llm_parsing: ScanText,
mcp: Cable,
};
export function isAiAddon(addon: Addon): boolean {
return (AI_ADDON_IDS as readonly string[]).includes(addon.id);
}
/** The AI addons this install has, in the tile's order. */
export function aiAddonsOf(addons: Addon[]): Addon[] {
return AI_ADDON_IDS.flatMap((id) => addons.find((a) => a.id === id) ?? []);
}
/** Whether the shared connection is used at all: the assistant or AI parsing is on. */
export function usesConnection(addons: Addon[]): boolean {
return addons.some((a) => (a.id === 'ai_assistant' || a.id === 'llm_parsing') && a.enabled);
}
@@ -2,13 +2,11 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { adminApi } from '../../../api/client';
import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps';
import { wandererAdminApi } from '../../../api/wanderer';
import { useAddonStore } from '../../../store/addonStore';
import type { Addon } from './addonModel';
import { useAddonManager } from './useAddonManager';
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-008
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-006
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
@@ -26,8 +24,6 @@ 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>>>;
@@ -39,10 +35,6 @@ beforeEach(() => {
useAddonStore.setState({ loadAddons });
vi.spyOn(adminApi, 'addons').mockResolvedValue({ addons: ADDONS });
vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({});
vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false });
vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true });
vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true });
vi.spyOn(skiRoutingAdminApi, 'get').mockResolvedValue({ enabled: true });
});
afterEach(() => vi.restoreAllMocks());
@@ -54,24 +46,15 @@ async function loaded() {
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
/** One of the map switches under Tours, by its key. */
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle' | 'ski') {
const found = manager.mapSwitches.find((entry) => entry.key === key);
if (!found) throw new Error(`no ${key} map switch`);
return found;
}
describe('useAddonManager', () => {
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration, AI and the two shelves', async () => {
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration 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']);
// 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(ids(result.current.integrationAddons)).toEqual(['llm_parsing']);
expect(result.current.providerOptions).toEqual([
expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }),
]);
@@ -132,43 +115,4 @@ describe('useAddonManager', () => {
await act(() => result.current.documentProviderOptions[0].toggle());
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('gdrive', { enabled: false });
});
it('FE-HOOK-ADDONMGR-007: the hiking map switch under Tours loads, flips, saves and refreshes the store (MAP-01)', async () => {
const update = vi.spyOn(hikingMapAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(mapSwitch(result.current, 'hiking').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'hiking').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(mapSwitch(result.current, 'hiking').enabled).toBe(false);
expect(loadAddons).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith('admin.addons.toast.updated');
});
it('FE-HOOK-ADDONMGR-009: the cycling map switch under Tours loads, flips and saves on its own (BIK-06)', async () => {
const update = vi.spyOn(cycleMapAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(mapSwitch(result.current, 'cycle').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'cycle').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(mapSwitch(result.current, 'cycle').enabled).toBe(false);
expect(mapSwitch(result.current, 'hiking').enabled).toBe(true);
});
it('FE-HOOK-ADDONMGR-010: the piste router switch under Tours loads on, flips and saves on its own (SKI-02)', async () => {
const update = vi.spyOn(skiRoutingAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(mapSwitch(result.current, 'ski').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'ski').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(mapSwitch(result.current, 'ski').enabled).toBe(false);
expect(mapSwitch(result.current, 'cycle').enabled).toBe(true);
});
it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => {
vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500'));
const { result } = await loaded();
await act(() => result.current.toggleToursWanderer());
expect(result.current.toursWanderer).toBe(false);
expect(toast.error).toHaveBeenCalledWith('admin.addons.toast.error');
});
});
@@ -1,27 +1,11 @@
import type { AdminFeatureToggleResponse } from '@trek/shared';
import { Bike, Footprints, MountainSnow } from 'lucide-react';
import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps';
import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useToast } from '../../shared/Toast';
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
import { aiAddonsOf, isAiAddon } from './aiAddons';
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
function loadToursFlag(
api: { get: () => Promise<AdminFeatureToggleResponse> },
set: (enabled: boolean) => void,
onError: () => void
) {
api
.get()
.then((flag) => set(flag.enabled))
.catch(onError);
}
/**
* The addon switches behind both admin shells: the addon list loaded on mount, an
@@ -36,9 +20,6 @@ export function useAddonManager() {
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
const [toursHikingMap, setToursHikingMap] = useState(false);
const [toursCycleMap, setToursCycleMap] = useState(false);
const [toursSkiRouting, setToursSkiRouting] = useState(false);
const loadAddons = async () => {
try {
@@ -51,11 +32,10 @@ export function useAddonManager() {
const loadOnMount = useEffectEvent(() => {
void loadAddons().finally(() => setLoading(false));
const unreadable = () => toast.error(t('admin.addons.toast.error'));
loadToursFlag(wandererAdminApi, setToursWanderer, unreadable);
loadToursFlag(hikingMapAdminApi, setToursHikingMap, unreadable);
loadToursFlag(cycleMapAdminApi, setToursCycleMap, unreadable);
loadToursFlag(skiRoutingAdminApi, setToursSkiRouting, unreadable);
wandererAdminApi
.get()
.then((flag) => setToursWanderer(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
});
useEffect(() => {
loadOnMount();
@@ -83,52 +63,20 @@ export function useAddonManager() {
toast.success(t('admin.addons.toast.updated'));
};
/** A switch under Tours (wanderer, OpenHikingMap, CyclOSM): flipped at once, put back when the save fails. */
const flipToursSwitch = async (
current: boolean,
set: (enabled: boolean) => void,
save: (enabled: boolean) => Promise<unknown>
) => {
const next = !current;
set(next);
/** wanderer under Tours: flipped at once, put back when the save fails. */
const toggleToursWanderer = async () => {
const next = !toursWanderer;
setToursWanderer(next);
try {
await save(next);
await wandererAdminApi.update(next);
} catch {
set(!next);
setToursWanderer(!next);
toast.error(t('admin.addons.toast.error'));
return;
}
void refreshGlobalAddons();
toast.success(t('admin.addons.toast.updated'));
};
const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update);
/** The maps and the piste router Tours offers on their operators' terms, one switch each (MAP-01, BIK-06, SKI-02). */
const mapSwitches = [
{
key: 'hiking',
Icon: Footprints,
titleKey: 'admin.toursHikingMap.title',
subtitleKey: 'admin.toursHikingMap.subtitle',
enabled: toursHikingMap,
toggle: () => flipToursSwitch(toursHikingMap, setToursHikingMap, hikingMapAdminApi.update),
},
{
key: 'cycle',
Icon: Bike,
titleKey: 'admin.toursCycleMap.title',
subtitleKey: 'admin.toursCycleMap.subtitle',
enabled: toursCycleMap,
toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update),
},
{
key: 'ski',
Icon: MountainSnow,
titleKey: 'admin.toursSkiRouting.title',
subtitleKey: 'admin.toursSkiRouting.subtitle',
enabled: toursSkiRouting,
toggle: () => flipToursSwitch(toursSkiRouting, setToursSkiRouting, skiRoutingAdminApi.update),
},
] as const;
const asOption = (provider: Addon): ProviderOption => ({
key: provider.id,
@@ -140,9 +88,7 @@ export function useAddonManager() {
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
const globalAddons = addons.filter((a) => a.type === 'global');
// 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 integrationAddons = addons.filter((a) => a.type === 'integration');
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
@@ -156,11 +102,9 @@ export function useAddonManager() {
tripAddons,
globalAddons,
integrationAddons,
aiAddons,
providerOptions,
documentProviderOptions,
toursWanderer,
toggleToursWanderer,
mapSwitches,
};
}
@@ -1,23 +0,0 @@
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),
};
}
@@ -1,167 +0,0 @@
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
import { MASKED_SETTING_VALUE } from '@trek/shared';
import { createElement, type ReactNode } from 'react';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { useAssistantStore } from '../../../store/assistantStore';
const repo = vi.hoisted(() => ({
status: vi.fn(),
adminSettings: vi.fn(),
saveAdminSettings: vi.fn(),
adminConnection: vi.fn(),
saveAdminConnection: vi.fn(),
removeAdminConnection: vi.fn(),
adminModels: vi.fn(),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const { useAiAdminConfig } = await import('./useAiAdminConfig');
const wrapper = ({ children }: { children: ReactNode }) =>
createElement(
MemoryRouter,
{ initialEntries: ['/admin?tab=addons'] },
createElement(TranslationProvider, null, children)
);
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
const LIVE: AiAdminConnectionResponse = {
connection: {
scope: 'instance',
kind: 'anthropic',
model: 'claude-sonnet',
hint: '…a1b2',
status: 'ok',
host: 'api.anthropic.com',
baseUrl: null,
},
prefill: null,
};
async function renderLoaded() {
const view = renderHook(() => useAiAdminConfig(), { wrapper });
await waitFor(() => expect(view.result.current.loaded).toBe(true));
return view;
}
beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
repo.status.mockRejectedValue(new Error('not needed'));
repo.adminSettings.mockResolvedValue(SETTINGS);
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
repo.adminConnection.mockResolvedValue(NONE);
repo.saveAdminConnection.mockResolvedValue(LIVE);
repo.removeAdminConnection.mockResolvedValue(undefined);
repo.adminModels.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]);
});
describe('useAiAdminConfig', () => {
it('FE-HOOK-AI-ADMIN-001: loads the settings and, without a connection, opens the setup for everyone', async () => {
const { result } = await renderLoaded();
expect(result.current.policy).toBe('instance_or_user');
expect(result.current.policyHintKey).toBe('assistant.admin.policy.instanceOrUserHint');
expect(result.current.instancePays).toBe(true);
expect(result.current.showPanel).toBe(true);
expect(result.current.dailyDraft).toBe('50');
expect(result.current.retention).toBe('90');
expect(result.current.retentionOptions.map((o) => o.label)).toEqual(['30 days', '90 days', '365 days', 'Forever']);
expect(repo.status).toHaveBeenCalled();
});
it('FE-HOOK-AI-ADMIN-002: a policy change saves at once, and a refused one is taken back', async () => {
const { result } = await renderLoaded();
await act(async () => result.current.setPolicy('user'));
await waitFor(() => expect(result.current.policy).toBe('user'));
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
expect(result.current.instancePays).toBe(false);
repo.saveAdminSettings.mockRejectedValueOnce(new Error('nope'));
await act(async () => result.current.setPolicy('instance'));
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
expect(result.current.policy).toBe('user');
});
it('FE-HOOK-AI-ADMIN-003: the daily limit is kept within 1 to 10000 and saved when the field is left', async () => {
const { result } = await renderLoaded();
act(() => result.current.setDailyDraft('20000'));
await act(async () => result.current.commitDailyLimit());
expect(result.current.dailyDraft).toBe('10000');
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, dailyTurns: 10000 });
vi.clearAllMocks();
act(() => result.current.setDailyDraft(''));
await act(async () => result.current.commitDailyLimit());
expect(result.current.dailyDraft).toBe('10000');
expect(repo.saveAdminSettings).not.toHaveBeenCalled();
});
it('FE-HOOK-AI-ADMIN-004: a retention period set elsewhere stays selectable', async () => {
repo.adminSettings.mockResolvedValue({ ...SETTINGS, retention: { mode: 'days', days: 120 } });
const { result } = await renderLoaded();
expect(result.current.retentionOptions.map((o) => o.value)).toEqual(['30', '90', '120', '365', 'forever']);
await act(async () => result.current.setRetention('forever'));
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, retention: { mode: 'forever' } });
});
it('FE-HOOK-AI-ADMIN-005: another model keeps the stored key by sending the mask', async () => {
repo.adminConnection.mockResolvedValue(LIVE);
const { result } = await renderLoaded();
expect(result.current.showPanel).toBe(false);
await waitFor(() => expect(result.current.instance.models).toHaveLength(1));
await act(async () => result.current.instance.setModel('claude-haiku'));
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
kind: 'anthropic',
apiKey: MASKED_SETTING_VALUE,
model: 'claude-haiku',
});
});
it('FE-HOOK-AI-ADMIN-006: an own server without a key sends none', async () => {
repo.adminConnection.mockResolvedValue({
connection: {
scope: 'instance',
kind: 'custom',
model: 'llama3',
hint: null,
status: 'ok',
host: 'ollama:11434',
baseUrl: 'http://ollama:11434/v1',
},
prefill: null,
});
const { result } = await renderLoaded();
await act(async () => result.current.instance.setModel('qwen3:8b'));
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
kind: 'custom',
baseUrl: 'http://ollama:11434/v1',
model: 'qwen3:8b',
});
});
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
const { result } = await renderLoaded();
act(() => result.current.instance.replace());
expect(result.current.showPanel).toBe(true);
act(() => result.current.instance.cancelReplace());
expect(result.current.showPanel).toBe(false);
act(() => result.current.instance.askRemove());
expect(result.current.instance.confirmingRemove).toBe(true);
act(() => result.current.instance.remove());
await waitFor(() => expect(result.current.instance.live).toBeNull());
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
expect(result.current.instance.confirmingRemove).toBe(false);
expect(result.current.showPanel).toBe(true);
});
});
@@ -1,221 +0,0 @@
import {
MASKED_SETTING_VALUE,
type AiAdminConnectionResponse,
type AiAdminSettings,
type AiInstanceConnectionRequest,
type AiPolicy,
type AiRetention,
} from '@trek/shared';
import { useEffect, useEffectEvent, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
import { useAssistantStore } from '../../../store/assistantStore';
import { providerLabel } from '../../Assistant/providerCatalog';
import { connectErrorKey } from '../../Settings/ai/aiConnectModel';
import { useOpenrouterReturn } from '../../Settings/ai/useAiConnect';
import { modelOptions, useAiModels } from '../../Settings/ai/useAiConnections';
import { useToast } from '../../shared/Toast';
/** Who pays, as the admin reads it ("I pay" rather than "the instance"); the first is the default. */
export const POLICY_CHOICES: readonly { value: AiPolicy; labelKey: string; hintKey: string }[] = [
{
value: 'instance_or_user',
labelKey: 'assistant.admin.policy.instanceOrUser',
hintKey: 'assistant.admin.policy.instanceOrUserHint',
},
{ value: 'instance', labelKey: 'assistant.admin.policy.instance', hintKey: 'assistant.admin.policy.instanceHint' },
{ value: 'user', labelKey: 'assistant.admin.policy.user', hintKey: 'assistant.admin.policy.userHint' },
];
const RETENTION_DAYS = [30, 90, 365];
const FOREVER = 'forever';
const MAX_DAILY_TURNS = 10_000;
/** A retention as the select's value: its days, or `forever`. */
function retentionValue(retention: AiRetention): string {
return retention.mode === 'forever' ? FOREVER : String(retention.days);
}
function retentionOf(value: string): AiRetention {
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
}
/** Policy, daily limit and retention: each change is saved at once and taken back when the server refuses it. */
function useAdminSettings(onSaved: () => void) {
const { t } = useTranslation();
const toast = useToast();
const [settings, setSettings] = useState<AiAdminSettings | null>(null);
const [dailyDraft, setDailyDraft] = useState('');
const adopt = (next: AiAdminSettings) => {
setSettings(next);
setDailyDraft(String(next.dailyTurns));
};
const save = async (patch: Partial<AiAdminSettings>) => {
if (!settings) return;
const before = settings;
setSettings({ ...settings, ...patch });
try {
adopt(await aiConnectionsRepo.saveAdminSettings({ ...settings, ...patch }));
onSaved();
} catch {
adopt(before);
toast.error(t('assistant.error.generic'));
}
};
/** The typed limit, kept within what the server takes, saved when the field is left. */
const commitDailyLimit = () => {
if (!settings) return;
const typed = Number.parseInt(dailyDraft, 10);
const next = Number.isFinite(typed) ? Math.min(MAX_DAILY_TURNS, Math.max(1, typed)) : settings.dailyTurns;
setDailyDraft(String(next));
if (next !== settings.dailyTurns) void save({ dailyTurns: next });
};
const current = settings ? retentionValue(settings.retention) : '';
// A period set some other way stays selectable instead of reading as blank.
const unlisted = current !== '' && current !== FOREVER && !RETENTION_DAYS.includes(Number(current));
const days = unlisted ? [...RETENTION_DAYS, Number(current)].sort((a, b) => a - b) : RETENTION_DAYS;
return {
settings,
adopt,
setPolicy: (policy: AiPolicy) => void save({ policy }),
dailyDraft,
setDailyDraft,
commitDailyLimit,
retention: current,
retentionOptions: [
...days.map((d) => ({ value: String(d), label: t('assistant.admin.retentionDays', { count: d }) })),
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
],
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
};
}
/** The connection for everyone: shown, its model switched, replaced or removed. */
function useInstanceConnection(onChanged: () => void) {
const { t } = useTranslation();
const toast = useToast();
const [connection, setConnection] = useState<AiAdminConnectionResponse | null>(null);
const [replacing, setReplacing] = useState(false);
const [confirmingRemove, setConfirmingRemove] = useState(false);
const [busy, setBusy] = useState(false);
const live = connection?.connection ?? null;
const provider = live ? providerLabel(live.kind, live.host) : '';
const models = useAiModels(
live?.status === 'ok' ? () => aiConnectionsRepo.adminModels() : null,
live ? `${live.kind}@${live.host}` : ''
);
const act = async (action: () => Promise<AiAdminConnectionResponse>, doneKey: string | null) => {
setBusy(true);
try {
setConnection(await action());
onChanged();
if (doneKey) toast.success(t(doneKey));
} catch (err) {
toast.error(t(connectErrorKey(err), { provider }));
} finally {
setBusy(false);
}
};
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
const setModel = (model: string) => {
if (!live || live.kind === 'chatgpt') return;
const request: AiInstanceConnectionRequest =
live.kind === 'custom'
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
: { kind: live.kind, apiKey: MASKED_SETTING_VALUE, model };
void act(() => aiConnectionsRepo.saveAdminConnection(request), 'common.saved');
};
const remove = () => {
setConfirmingRemove(false);
void act(async () => {
await aiConnectionsRepo.removeAdminConnection();
return aiConnectionsRepo.adminConnection();
}, null);
};
const onConnected = () => {
setReplacing(false);
void act(() => aiConnectionsRepo.adminConnection(), null);
};
return {
connection,
setConnection,
live,
provider,
models: live
? modelOptions(models, live.model, {
free: t('assistant.model.free'),
recommended: t('assistant.model.recommended'),
})
: [],
busy,
setModel,
replacing,
replace: () => setReplacing(true),
cancelReplace: () => setReplacing(false),
onConnected,
confirmingRemove,
askRemove: () => setConfirmingRemove(true),
cancelRemove: () => setConfirmingRemove(false),
remove,
};
}
/**
* The AI assistant's admin settings behind both admin shells, on the addon's
* own tile: who pays, the connection everyone runs on, the daily limit while
* the instance pays, and how long chats are kept. The assistant's status is
* reloaded after every change, so the launcher appears or goes at once.
*/
export function useAiAdminConfig() {
const { t } = useTranslation();
const toast = useToast();
const loadStatus = useAssistantStore((s) => s.loadStatus);
const refreshStatus = () => void loadStatus();
const form = useAdminSettings(refreshStatus);
const instance = useInstanceConnection(refreshStatus);
useOpenrouterReturn(instance.onConnected);
const loadOnMount = useEffectEvent((isCancelled: () => boolean) => {
Promise.all([aiConnectionsRepo.adminSettings(), aiConnectionsRepo.adminConnection()]).then(
([settings, connection]) => {
if (isCancelled()) return;
form.adopt(settings);
instance.setConnection(connection);
},
() => {
if (!isCancelled()) toast.error(t('assistant.error.generic'));
}
);
// The connect panel reads whether OpenRouter can return here from the status.
refreshStatus();
});
useEffect(() => {
let cancelled = false;
loadOnMount(() => cancelled);
return () => {
cancelled = true;
};
}, []);
const policy = form.settings?.policy ?? null;
return {
...form,
instance,
loaded: form.settings !== null && instance.connection !== null,
policy,
policyHintKey: POLICY_CHOICES.find((c) => c.value === policy)?.hintKey ?? null,
/** Policy `user` charges nothing to the instance: no connection and no limit to set. */
instancePays: policy !== null && policy !== 'user',
showPanel: instance.live === null || instance.replacing,
};
}
@@ -5,14 +5,10 @@ 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-013
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-011
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 {
@@ -49,7 +45,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(OWN_LOCAL)));
const { result } = renderHook(() => useLlmParsingConfig(llm()));
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);
@@ -58,7 +54,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', model: 'gpt', baseUrl: ' http://box:11434/v1 ' }))
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
);
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
await act(() => result.current.loadModels());
@@ -73,13 +69,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(OWN_LOCAL)));
const { result } = renderHook(() => useLlmParsingConfig(llm()));
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('admin.addons.ai.parsing.unreachable');
expect(result.current.modelsErr).toBe('Could not reach the local LLM server');
});
it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => {
@@ -87,7 +83,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(OWN_LOCAL)));
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)'));
});
@@ -98,14 +94,14 @@ describe('useLlmParsingConfig', () => {
progress = onProgress;
return new Promise<void>((r) => (finish = r));
});
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
const { result } = renderHook(() => useLlmParsingConfig(llm()));
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('admin.addons.ai.parsing.pulling');
expect(result.current.pullStatus).toBe('starting…');
act(() => progress({ status: 'downloading', total: 200, completed: 50 }));
expect(result.current.pullStatus).toBe('downloading');
expect(result.current.pullPct).toBe(25);
@@ -113,7 +109,7 @@ describe('useLlmParsingConfig', () => {
finish();
await pending;
});
expect(toast.success).toHaveBeenCalledWith('admin.addons.ai.parsing.pulled');
expect(toast.success).toHaveBeenCalledWith('Model pulled');
expect(result.current.model).toBe('qwen3.5:4b');
expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2);
expect(result.current.pulling).toBeNull();
@@ -140,7 +136,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('admin.addons.ai.parsing.pullFailed');
expect(toast.error).toHaveBeenLastCalledWith('Pull failed');
expect(result.current.pulling).toBeNull();
});
@@ -152,17 +148,18 @@ 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('settings.aiParsing.toast.saved');
expect(toast.success).toHaveBeenCalledWith('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' })));
await act(async () => result.current.setVision('off'));
act(() => result.current.setProvider('anthropic'));
act(() => {
result.current.setProvider('anthropic');
result.current.setVision('off');
});
await act(() => result.current.save());
const calls = vi.mocked(adminApi.updateAddon).mock.calls;
expect(calls[calls.length - 1]?.[1]).toEqual({
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1]).toEqual({
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
});
});
@@ -215,35 +212,7 @@ 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('settings.aiParsing.toast.saveError');
expect(toast.error).toHaveBeenCalledWith('Failed to save');
expect(result.current.saving).toBe(false);
});
it('FE-HOOK-LLMCFG-012: without its own connection the form stays shut and asks the server nothing until opened', async () => {
const { result } = renderHook(() => useLlmParsingConfig(llm({ vision: 'on' })));
expect(result.current.ownConnection).toBe(false);
expect(result.current.formOpen).toBe(false);
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
act(() => result.current.startOwn());
expect(result.current.formOpen).toBe(true);
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
// The vision setting alone is saved without a connection.
await act(async () => result.current.setVision('off'));
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('llm_parsing', { config: { vision: 'off' } });
act(() => result.current.cancelOwn());
expect(result.current.formOpen).toBe(false);
});
it('FE-HOOK-LLMCFG-013: going back to the shared connection drops the own one and keeps the vision setting', async () => {
const { result } = renderHook(() =>
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'claude-x', apiKey: MASKED, vision: 'on' }))
);
expect(result.current.ownConnection).toBe(true);
expect(result.current.savedModel).toBe('claude-x');
await act(() => result.current.switchToShared());
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', { config: { vision: 'on' } });
expect(result.current.ownConnection).toBe(false);
expect(result.current.formOpen).toBe(false);
expect(result.current.model).toBe('');
});
});
@@ -2,118 +2,33 @@ import { type LlmVision, asLlmVision } from '@trek/shared';
import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { useTranslation } from '../../../i18n';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
/** What AI parsing keeps in its addon config: a connection of its own (or none) and the vision setting. */
interface SavedParsing {
provider: string;
model: string;
baseUrl: string;
apiKey: string;
vision: LlmVision;
}
function savedOf(cfg: Record<string, unknown>): SavedParsing {
return {
provider: typeof cfg.provider === 'string' ? cfg.provider : '',
model: typeof cfg.model === 'string' ? cfg.model : '',
baseUrl: typeof cfg.baseUrl === 'string' ? cfg.baseUrl : '',
apiKey: typeof cfg.apiKey === 'string' ? cfg.apiKey : '',
vision: asLlmVision(cfg.vision),
};
}
/**
* The models a local (Ollama) server has, and pulling the recommended ones.
* The list loads while the form shows the local provider: a provider change or
* opening the form loads it, the address field's own blur reloads it.
* Instance-wide AI-parsing config behind both admin shells. When set, it applies to the
* whole instance and overrides per-user config (see server llmConfig.ts). The API key is
* masked on read; an unchanged mask is treated as a no-op by the server. For the local
* provider it also lists the installed Ollama models and can pull the recommended ones.
*/
function useLocalModels(baseUrl: string, isLocal: boolean, formOpen: boolean, onPulled: (id: string) => void) {
const { t } = useTranslation();
export function useLlmParsingConfig(addon: Addon) {
const toast = useToast();
const cfg = addon.config ?? {};
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
const [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision));
const [saving, setSaving] = useState(false);
// Local-provider model management.
const [installed, setInstalled] = useState<string[]>([]);
const [modelsErr, setModelsErr] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const [pulling, setPulling] = useState<string | null>(null);
const [pullPct, setPullPct] = useState(0);
const [pullStatus, setPullStatus] = useState('');
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
const loadModels = async () => {
if (!isLocal) return;
setLoadingModels(true);
setModelsErr('');
try {
const res = await adminApi.llmLocalModels(effectiveUrl);
setInstalled(res.models.map((m) => m.name));
} catch (e: unknown) {
// The server says why (unreachable, key refused); axios' own status line does not.
setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable')));
setInstalled([]);
} finally {
setLoadingModels(false);
}
};
const loadModelsForProvider = useEffectEvent(() => {
if (formOpen && isLocal) void loadModels();
});
useEffect(() => {
loadModelsForProvider();
}, [isLocal, formOpen]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
setPullStatus(t('admin.addons.ai.parsing.pulling'));
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error);
if (p.status) setPullStatus(p.status);
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
});
toast.success(t('admin.addons.ai.parsing.pulled'));
onPulled(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
} finally {
setPulling(null);
setPullPct(0);
setPullStatus('');
}
};
return { installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull };
}
/**
* AI parsing's admin settings behind both admin shells. Parsing reads with the
* AI connection the assistant runs on unless the instance keeps one of its own
* here; an install set up before the two shared one keeps its own until the
* admin switches it over. The API key is masked on read and an unchanged mask
* keeps the stored key. For a local server it lists the installed Ollama models
* and pulls the recommended ones.
*/
export function useLlmParsingConfig(addon: Addon) {
const { t } = useTranslation();
const toast = useToast();
const [saved, setSaved] = useState<SavedParsing>(() => savedOf(addon.config ?? {}));
const [editing, setEditing] = useState(false);
const [provider, setProviderState] = useState<string>(saved.provider || 'local');
const [model, setModel] = useState<string>(saved.model);
const [baseUrl, setBaseUrl] = useState<string>(saved.baseUrl);
const [apiKey, setApiKey] = useState<string>(saved.apiKey);
const [saving, setSaving] = useState(false);
/** A connection of its own, which wins over the shared one. */
const ownConnection = !!(saved.provider && saved.model);
const formOpen = ownConnection || editing;
/**
* A different provider never inherits the last one's endpoint: an Ollama address
@@ -122,58 +37,83 @@ export function useLlmParsingConfig(addon: Addon) {
* keep an address the admin typed for it (#2655); empty means the provider default.
*/
const setProvider = (next: string) => {
if (next !== provider) setBaseUrl(next === saved.provider ? saved.baseUrl : '');
if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : '');
setProviderState(next);
};
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
const local = useLocalModels(baseUrl, provider === 'local', formOpen, setModel);
/** Writes the config; the stored key stays as long as the mask is sent in its place. */
const write = async (next: SavedParsing, config: Record<string, unknown>): Promise<boolean> => {
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
const loadModels = async () => {
if (provider !== 'local') return;
setLoadingModels(true);
setModelsErr('');
try {
const res = await adminApi.llmLocalModels(effectiveUrl);
setInstalled(res.models.map((m) => m.name));
} catch (e: unknown) {
// The server says why (unreachable, key refused); axios' own status line does not.
setModelsErr(getApiErrorMessage(e, 'Could not reach the local LLM server'));
setInstalled([]);
} finally {
setLoadingModels(false);
}
};
// Load installed models when the local provider is active. Only a provider change
// triggers it; the base URL is read as it stands then (its own blur reloads).
const loadModelsForProvider = useEffectEvent(() => {
if (provider === 'local') void loadModels();
});
useEffect(() => {
loadModelsForProvider();
}, [provider]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
setPullStatus('starting…');
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error);
if (p.status) setPullStatus(p.status);
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
});
toast.success('Model pulled');
setModel(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed');
} finally {
setPulling(null);
setPullPct(0);
setPullStatus('');
}
};
const save = async () => {
setSaving(true);
try {
await adminApi.updateAddon(addon.id, { config });
setSaved(next);
toast.success(t('settings.aiParsing.toast.saved'));
return true;
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, {
config: {
provider,
model: model.trim(),
baseUrl: baseUrl.trim(),
apiKey,
vision,
},
});
toast.success('Saved');
} catch {
toast.error(t('settings.aiParsing.toast.saveError'));
return false;
toast.error('Failed to save');
} finally {
setSaving(false);
}
};
const ownConfig = (s: SavedParsing) => ({
provider: s.provider,
model: s.model,
baseUrl: s.baseUrl,
apiKey: s.apiKey,
});
const save = async () => {
const next: SavedParsing = { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, vision: saved.vision };
if (await write(next, { ...ownConfig(next), vision: next.vision })) setEditing(false);
};
/** Back on the shared connection: the own one and its key are dropped, the vision setting stays. */
const switchToShared = async () => {
const next: SavedParsing = { provider: '', model: '', baseUrl: '', apiKey: '', vision: saved.vision };
if (await write(next, { vision: next.vision })) {
setEditing(false);
setProviderState('local');
setModel('');
setBaseUrl('');
setApiKey('');
}
};
/** Saved at once, with the connection as it was saved: a draft in the form is not sent along. */
const setVision = (value: LlmVision) => {
const next = { ...saved, vision: value };
void write(next, ownConnection ? { ...ownConfig(saved), vision: value } : { vision: value });
};
return {
provider,
setProvider,
@@ -184,17 +124,18 @@ export function useLlmParsingConfig(addon: Addon) {
baseUrlPlaceholder,
apiKey,
setApiKey,
vision: saved.vision,
vision,
setVision,
saving,
ownConnection,
savedProvider: saved.provider,
savedModel: saved.model,
formOpen,
startOwn: () => setEditing(true),
cancelOwn: () => setEditing(false),
switchToShared,
...local,
installed,
modelsErr,
loadingModels,
pulling,
pullPct,
pullStatus,
isInstalled,
loadModels,
pull,
save,
};
}
@@ -1,167 +0,0 @@
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
const SIZES = [100, 250, 500, 1000, 2000] as const;
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
function useMegabytes() {
const { language } = useTranslation();
return (mb: number) =>
new Intl.NumberFormat(language, {
style: 'unit',
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
maximumFractionDigits: mb >= 1000 ? 1 : 0,
}).format(mb >= 1000 ? mb / 1000 : mb);
}
const MB = 1024 * 1024;
/**
* PAD-11C: how TREK gets the water data of a place a paddler plans in. The
* mode, and in the automatic one the largest region it loads without asking.
*/
export function AutoLoadSettings({ admin }: { admin: Admin }) {
const { t } = useTranslation();
const megabytes = useMegabytes();
const { settings, saving, save } = admin.autoLoad;
if (!settings) return null;
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
? SIZES
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
return (
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
<div className="min-w-0">
<span className={LABEL}>{t('admin.paddleRegions.auto.title')}</span>
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
<Segmented<PaddleRegionAutoMode>
value={settings.mode}
onChange={(mode) => void save({ mode })}
label={t('admin.paddleRegions.auto.title')}
fill
options={MODES.map((mode) => {
const Icon = MODE_ICONS[mode];
return {
value: mode,
label: t(`admin.paddleRegions.auto.mode.${mode}`),
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
};
})}
/>
</fieldset>
<SettingsHint className="mt-1.5">{t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)}</SettingsHint>
</div>
{settings.mode === 'auto' && (
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
<span className={LABEL}>{t('admin.paddleRegions.auto.maxSize')}</span>
<ChoiceChips<string>
value={String(settings.max_mb)}
label={t('admin.paddleRegions.auto.maxSize')}
onChange={(value) => void save({ max_mb: Number(value) })}
options={sizes.map((mb) => ({ value: String(mb), label: megabytes(mb) }))}
/>
<SettingsHint className="mt-1.5">{t('admin.paddleRegions.auto.maxSizeHint')}</SettingsHint>
</fieldset>
)}
</div>
);
}
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
const { t, language } = useTranslation();
const megabytes = useMegabytes();
const busy = admin.busyId === request.id;
const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
return (
<div
className={`flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
data-testid="paddle-region-request"
>
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-1">
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
{request.name}
</span>
<span className="flex flex-wrap items-center gap-1.5">
{request.declined ? (
<StatusPill tone="danger" icon={<Ban size={10} strokeWidth={2.4} />}>
{t('admin.paddleRegions.requests.declined')}
</StatusPill>
) : (
<StatusPill tone="warning">{t(`admin.paddleRegions.requests.reason.${request.reason}`)}</StatusPill>
)}
{request.bytes !== null && <StatusPill>{megabytes(Math.max(1, Math.round(request.bytes / MB)))}</StatusPill>}
{request.requested_by && (
<StatusPill>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</StatusPill>
)}
<StatusPill>{date}</StatusPill>
</span>
</div>
<span className="flex flex-none items-center gap-1.5">
{!request.declined && (
<button
type="button"
disabled={admin.busyId !== null}
onClick={() => void admin.decline(request.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{t('admin.paddleRegions.requests.decline')}
</button>
)}
<button
type="button"
disabled={admin.busyId !== null}
onClick={() => void admin.approve(request.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
{t('admin.paddleRegions.requests.approve')}
</button>
</span>
</div>
);
}
/** The regions paddlers asked for that wait for an admin, the open ones first. */
export function RequestList({ admin }: { admin: Admin }) {
const { t } = useTranslation();
if (admin.requests.length === 0) return null;
const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined));
const open = admin.requests.filter((request) => !request.declined).length;
return (
<div className="flex min-w-0 flex-col gap-2" data-testid="paddle-region-requests">
<span className={`${LABEL} mb-0 flex items-center gap-1.5`}>
{t('admin.paddleRegions.requests.title')}
{open > 0 && <StatusPill tone="warning">{open}</StatusPill>}
</span>
<SettingRows>
{ordered.map((request) => (
<RequestRow key={request.id} request={request} admin={admin} />
))}
</SettingRows>
</div>
);
}
/** A region TREK loaded by itself for a paddler, told apart from those an admin added. */
export function AutoOriginPill() {
const { t } = useTranslation();
return (
<Tooltip label={t('admin.paddleRegions.origin.autoHint')}>
<span className="inline-flex" data-testid="paddle-region-auto">
<StatusPill icon={<Sparkles size={10} strokeWidth={2.2} />}>{t('admin.paddleRegions.origin.auto')}</StatusPill>
</span>
</Tooltip>
);
}
@@ -1,4 +1,4 @@
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: the paddle regions card in the admin area, drawn from a
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
// controlled usePaddleRegionsAdmin (the hook has its own test).
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
@@ -25,13 +25,11 @@ function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
id: 'oberpfalz',
name: 'Oberpfalz',
status: 'ready',
origin: 'admin',
source_timestamp: '2026-10-03T12:00:00Z',
imported_at: '2026-10-04T08:00:00Z',
feature_count: 1234,
error: null,
progress: null,
network: null,
...overrides,
};
}
@@ -58,8 +56,6 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
toursOn: true,
offline: false,
regions: [],
requests: [],
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
loading: false,
error: null,
retry: vi.fn(),
@@ -67,8 +63,6 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
busyId: null,
add: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
approve: vi.fn(async () => {}),
decline: vi.fn(async () => {}),
removing: null,
askRemove: vi.fn(),
cancelRemove: vi.fn(),
@@ -301,91 +295,6 @@ describe('PaddleRegionsPanel', () => {
});
});
describe('PaddleRegionsPanel water network (PAD-11)', () => {
it('FE-ADMIN-PADDLEREG-022: a region with its network shows the water it holds, the rest in the tooltip', async () => {
const network = {
water_km: 1364.5,
lakes: 266,
portages: 14,
inferred_portages: 232,
gaps: 63,
locks: 17,
built_at: '2026-10-17T15:30:20Z',
bytes: 1496816,
};
show({ regions: [region({ network })] });
const pill = within(rowOf('Oberpfalz')).getByTestId('paddle-region-network');
expect(pill).toHaveTextContent('1,365 km of water');
fireEvent.mouseEnter(pill);
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'266 lakes, 14 mapped portages, 232 assumed carries, 17 locks, 63 bridged gaps'
);
});
it('FE-ADMIN-PADDLEREG-023: a region read before routing existed asks to be read again; one never read or being read says nothing', () => {
show({
regions: [
region({ id: 'old', name: 'Old read', network: null }),
region({ id: 'new', name: 'Never read', imported_at: null, status: 'queued', network: null }),
],
});
expect(within(rowOf('Old read')).getByText('No water network')).toBeInTheDocument();
expect(within(rowOf('Never read')).queryByText('No water network')).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-024: offers automatic, on request and off, with the size limit only for automatic', () => {
const save = vi.fn(async () => {});
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } });
const auto = screen.getByTestId('paddle-auto-load');
expect(within(auto).getByRole('button', { name: /Automatic/ })).toHaveAttribute('aria-pressed', 'true');
expect(within(auto).getByText('Largest region loaded automatically')).toBeInTheDocument();
fireEvent.click(within(auto).getByRole('button', { name: /On request/ }));
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
fireEvent.click(within(auto).getByRole('button', { name: '1 GB' }));
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
});
it('FE-ADMIN-PADDLEREG-025: hides the size limit unless loading is automatic, and shows nothing before the setting is read', () => {
show({ autoLoad: { settings: { mode: 'off', max_mb: 500 }, saving: false, save: vi.fn() } });
expect(screen.queryByText('Largest region loaded automatically')).not.toBeInTheDocument();
expect(screen.getByText(/TREK loads nothing by itself/)).toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-026: lists the requests, the open ones first, to load or decline', () => {
const approve = vi.fn(async () => {});
const decline = vi.fn(async () => {});
const base = {
bytes: 734_003_200,
requested_by: 'elmo',
requested_at: '2026-10-18T09:00:00Z',
};
show({
approve,
decline,
requests: [
{ ...base, id: 'norway', name: 'Norway', reason: 'too_large', declined: true },
{ ...base, id: 'brandenburg', name: 'Brandenburg', reason: 'limit', declined: false },
],
});
const rows = screen.getAllByTestId('paddle-region-request');
expect(rows[0]).toHaveTextContent('Brandenburg');
expect(rows[0]).toHaveTextContent('Daily limit');
expect(rows[0]).toHaveTextContent('by elmo');
expect(rows[1]).toHaveTextContent('Declined');
fireEvent.click(within(rows[0]).getByRole('button', { name: 'Decline' }));
expect(decline).toHaveBeenCalledWith('brandenburg');
fireEvent.click(within(rows[1]).getByRole('button', { name: /Load/ }));
expect(approve).toHaveBeenCalledWith('norway');
expect(within(rows[1]).queryByRole('button', { name: 'Decline' })).toBeNull();
});
it('FE-ADMIN-PADDLEREG-027: marks a region TREK loaded by itself', () => {
show({ regions: [region({ id: 'mv', name: 'Mecklenburg', origin: 'auto' }), READY] });
expect(within(rowOf('Mecklenburg')).getByTestId('paddle-region-auto')).toHaveTextContent('Automatic');
expect(within(rowOf('Oberpfalz')).queryByTestId('paddle-region-auto')).toBeNull();
});
});
describe('paddleRegionsModel', () => {
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
expect(isReading({ status: 'queued' })).toBe(true);
@@ -15,7 +15,6 @@ import { EditorField, INPUT } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import ErrorBoundary from '../../shared/ErrorBoundary';
import { Tooltip } from '../../shared/Tooltip';
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
@@ -23,10 +22,8 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
/**
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
* place a paddler plans in by itself, with the regions that wait for an admin.
* Both the desktop and the phone admin show this one card under the addons,
* while the Tours addon is on.
* picked from Geofabrik's list. Both the desktop and the phone admin show
* this one card under the addons, while the Tours addon is on.
*/
export default function PaddleRegionsPanel() {
return (
@@ -70,8 +67,6 @@ function Panel() {
</div>
)}
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
<AutoLoadSettings admin={admin} />
<RequestList admin={admin} />
<CatalogSearch admin={admin} />
<RegionList admin={admin} />
</fieldset>
@@ -221,41 +216,6 @@ function RegionStatus({ region }: { region: PaddleRegion }) {
);
}
/**
* PAD-11: the water network the router follows in a region, as one figure
* with the rest in its tooltip. A region read before routing existed says it
* needs reading again; a network is never claimed where none was built.
*/
function RegionNetwork({ region }: { region: PaddleRegion }) {
const { t, language } = useTranslation();
const { network } = region;
if (!network) {
if (!region.imported_at || region.status === 'importing') return null;
return (
<Tooltip label={t('admin.paddleRegions.network.missingHint')}>
<span className="inline-flex">
<StatusPill tone="warning">{t('admin.paddleRegions.network.missing')}</StatusPill>
</span>
</Tooltip>
);
}
const km = new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(network.water_km);
const details = [
t('admin.paddleRegions.network.lakes', { count: network.lakes }),
t('admin.paddleRegions.network.portages', { count: network.portages }),
t('admin.paddleRegions.network.inferred', { count: network.inferred_portages }),
t('admin.paddleRegions.network.locks', { count: network.locks }),
t('admin.paddleRegions.network.gaps', { count: network.gaps }),
].join(', ');
return (
<Tooltip label={details}>
<span className="inline-flex" data-testid="paddle-region-network">
<StatusPill>{t('admin.paddleRegions.network.km', { km })}</StatusPill>
</span>
</Tooltip>
);
}
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
const { t, language } = useTranslation();
const busy = admin.busyId === region.id;
@@ -279,12 +239,10 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
</span>
<span className="flex flex-wrap items-center gap-1.5">
<RegionStatus region={region} />
{region.origin === 'auto' && <AutoOriginPill />}
{region.imported_at && (
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
)}
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
<RegionNetwork region={region} />
</span>
{region.status === 'failed' && region.error && (
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
@@ -1,50 +0,0 @@
// FE-HOOK-PADDLE-AUTOLOAD-001 to 003: how TREK loads regions by itself, read and saved in the admin area (PAD-11C).
import { act, renderHook, waitFor } from '@testing-library/react';
import { createElement, type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TranslationProvider } from '../../../i18n/TranslationContext';
const repo = vi.hoisted(() => ({ settings: vi.fn(), saveSettings: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/paddleRegionRepo', () => ({ paddleRegionRepo: repo }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const { usePaddleAutoLoad } = await import('./usePaddleAutoLoad');
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
describe('usePaddleAutoLoad', () => {
beforeEach(() => {
vi.clearAllMocks();
repo.settings.mockResolvedValue({ mode: 'auto', max_mb: 500 });
repo.saveSettings.mockImplementation(async (settings: unknown) => settings);
});
it('FE-HOOK-PADDLE-AUTOLOAD-001: reads the setting once the card shows, and not offline', async () => {
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 }));
renderHook(() => usePaddleAutoLoad(true, true), { wrapper });
expect(repo.settings).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-PADDLE-AUTOLOAD-002: shows a change at once and keeps what the server saved', async () => {
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).not.toBeNull());
await act(async () => {
await result.current.save({ mode: 'ask' });
});
expect(repo.saveSettings).toHaveBeenCalledWith({ mode: 'ask', max_mb: 500 });
expect(result.current.settings).toEqual({ mode: 'ask', max_mb: 500 });
});
it('FE-HOOK-PADDLE-AUTOLOAD-003: takes a refused change back and says so', async () => {
repo.saveSettings.mockRejectedValueOnce(new Error('nope'));
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).not.toBeNull());
await act(async () => {
await result.current.save({ max_mb: 2000 });
});
expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 });
expect(toast.error).toHaveBeenCalledTimes(1);
});
});
@@ -1,59 +0,0 @@
import type { PaddleRegionSettings } from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { paddleRegionRepo } from '../../../repo/paddleRegionRepo';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
/**
* PAD-11C in the admin area: how TREK loads the water data of a place a
* paddler plans in, read once the card shows and saved on every change. A
* change shows at once and goes back, with a toast, when the server refuses
* it. While one is saved the controls rest, so two saves never cross.
*/
export function usePaddleAutoLoad(active: boolean, offline: boolean) {
const { t } = useTranslation();
const toast = useToast();
const [settings, setSettings] = useState<PaddleRegionSettings | null>(null);
const [saving, setSaving] = useState(false);
const pending = useRef(false);
useEffect(() => {
if (!active || offline) return;
let cancelled = false;
paddleRegionRepo.settings().then(
(loaded) => {
if (!cancelled) setSettings(loaded);
},
(err: unknown) => {
if (!cancelled) toast.error(getApiErrorMessage(err, t('common.error')));
}
);
return () => {
cancelled = true;
};
}, [active, offline, t, toast]);
const save = useCallback(
async (change: Partial<PaddleRegionSettings>) => {
if (!settings || pending.current) return;
const before = settings;
const next = { ...settings, ...change };
pending.current = true;
setSaving(true);
setSettings(next);
try {
setSettings(await paddleRegionRepo.saveSettings(next));
} catch (err) {
setSettings(before);
toast.error(getApiErrorMessage(err, t('common.error')));
} finally {
pending.current = false;
setSaving(false);
}
},
[settings, t, toast]
);
return { settings, saving, save };
}
@@ -24,15 +24,11 @@ const repo = vi.hoisted(() => ({
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
decline: vi.fn<(id: string) => Promise<{ success: true }>>(),
settings: vi.fn(async () => ({ mode: 'auto' as const, max_mb: 500 })),
saveSettings: vi.fn(async (settings: { mode: 'auto' | 'ask' | 'off'; max_mb: number }) => settings),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
// The list answers its regions and no requests unless a test says otherwise (PAD-11C).
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
paddleRegionRepo: repo,
}));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
@@ -44,13 +40,11 @@ function region(id: string, status: PaddleRegion['status'], over: Partial<Paddle
id,
name: id,
status,
origin: 'admin',
source_timestamp: null,
imported_at: null,
feature_count: 0,
error: null,
progress: null,
network: null,
...over,
};
}
@@ -1,4 +1,4 @@
import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared';
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { useTranslation } from '../../../i18n';
@@ -7,7 +7,6 @@ import { useAddonStore } from '../../../store/addonStore';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
import { isReading } from './paddleRegionsModel';
import { usePaddleAutoLoad } from './usePaddleAutoLoad';
/** How often the list is asked again while a region waits or is read. */
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
@@ -24,14 +23,12 @@ function errorText(t: Translate, err: unknown): string {
}
/**
* The regions as the server holds them, with those paddlers asked for, asked
* again every few seconds while any of them is read, and on `reload` after a
* change. Every answer is set
* The regions as the server holds them, asked again every few seconds while
* any of them is read, and on `reload` after a change. Every answer is set
* from the request's own callback, so a slow one never overwrites a newer.
*/
function useRegionList(active: boolean, offline: boolean, t: Translate) {
const [regions, setRegions] = useState<PaddleRegion[]>([]);
const [requests, setRequests] = useState<PaddleRegionPending[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [asked, setAsked] = useState(0);
@@ -41,8 +38,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
paddleRegionRepo.list().then(
(list) => {
if (cancelled) return;
setRegions(list.regions);
setRequests(list.requests);
setRegions(list);
setError(null);
setLoaded(true);
},
@@ -63,7 +59,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
return () => window.clearInterval(timer);
}, [active, offline, reading]);
const reload = useCallback(() => setAsked((n) => n + 1), []);
return { regions, requests, loading: !loaded && !offline, error, reload };
return { regions, loading: !loaded && !offline, error, reload };
}
/** Geofabrik's list searched as the admin types, the last search winning. */
@@ -103,8 +99,7 @@ function useCatalogSearch(offline: boolean, t: Translate) {
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
* in Geofabrik's list to add one, reading a region again and removing it
* after a confirmation. Shown only while the Tours addon is on; the status of
* a read follows without reloading the page. PAD-11C: how TREK loads regions
* for paddlers by itself, and the regions they asked for, to load or decline.
* a read follows without reloading the page.
*/
export function usePaddleRegionsAdmin() {
const { t } = useTranslation();
@@ -112,7 +107,6 @@ export function usePaddleRegionsAdmin() {
const { offline } = useNetworkMode();
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
const list = useRegionList(toursOn, offline, t);
const autoLoad = usePaddleAutoLoad(toursOn, offline);
const search = useCatalogSearch(offline, t);
const [busyId, setBusyId] = useState<string | null>(null);
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
@@ -152,12 +146,6 @@ export function usePaddleRegionsAdmin() {
[run, setQuery, t]
);
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
/** A paddler's request let through: the region is added like any other. */
const approve = useCallback(
(id: string) => run(id, () => paddleRegionRepo.add(id), t('admin.paddleRegions.toast.added')),
[run, t]
);
const decline = useCallback((id: string) => run(id, () => paddleRegionRepo.decline(id)), [run]);
const confirmRemove = useCallback(() => {
const region = removing;
setRemoving(null);
@@ -169,8 +157,6 @@ export function usePaddleRegionsAdmin() {
toursOn,
offline,
regions: list.regions,
requests: list.requests,
autoLoad,
loading: list.loading,
error: list.error,
retry: reload,
@@ -179,8 +165,6 @@ export function usePaddleRegionsAdmin() {
busyId,
add,
refresh,
approve,
decline,
removing,
askRemove: setRemoving,
cancelRemove: () => setRemoving(null),
@@ -24,7 +24,7 @@ export const CATEGORIES = [
{ id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
];
/**
@@ -1,56 +0,0 @@
/**
* FE-AI-ASK-001 to FE-AI-ASK-003
*
* "Ask" in the place inspector: there only while the assistant can run, only
* in the planner of the place's trip, and it opens the chat about the place.
*/
import type { AiStatusResponse } from '@trek/shared';
import { Route, Routes } from 'react-router';
import { afterEach, describe, expect, it } from 'vitest';
import { buildPlace } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import AssistantAskButton from './AssistantAskButton';
const INITIAL = useAssistantStore.getState();
const place = buildPlace({ id: 42, trip_id: 5, name: 'Kinkaku-ji' });
function renderAt(path: string) {
return render(
<Routes>
<Route path="/trips/:id" element={<AssistantAskButton place={place} />} />
<Route path="*" element={<AssistantAskButton place={place} />} />
</Routes>,
{ initialEntries: [path] }
);
}
afterEach(() => useAssistantStore.setState(INITIAL, true));
describe('AssistantAskButton', () => {
it('FE-AI-ASK-001: shows nothing until the assistant has a connection to use', () => {
renderAt('/trips/5');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('FE-AI-ASK-002: opens the assistant with the place as its context', () => {
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
renderAt('/trips/5');
fireEvent.click(screen.getByRole('button', { name: 'Ask: Kinkaku-ji' }));
const state = useAssistantStore.getState();
expect(state.window).toEqual({ open: true, minimized: false });
expect(chatOf(state, 5).focus).toEqual({ placeIds: [42] });
});
it('FE-AI-ASK-003: is not offered outside the planner of the trip the place belongs to', () => {
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
const { unmount } = renderAt('/trips/6');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
unmount();
renderAt('/collections/3');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
@@ -1,34 +0,0 @@
import { Sparkles } from 'lucide-react';
import { useMatch } from 'react-router';
import { useTranslation } from '../../i18n';
import type { Place } from '../../types';
import Tooltip from '../shared/Tooltip';
import { useAssistantAsk } from './useAssistantAsk';
/**
* The place inspector's "Ask": a round button in its head, beside close, that
* opens the assistant about this place. Only in the planner of the place's own
* trip; a saved place in a collection has no trip to ask about.
*/
export default function AssistantAskButton({ place }: { place: Place }) {
const { t } = useTranslation();
const inTrip = useMatch('/trips/:id');
const tripId = inTrip && Number(inTrip.params.id) === place.trip_id ? place.trip_id : null;
const { available, askAbout } = useAssistantAsk(tripId);
if (!available) return null;
const label = t('assistant.context.ask');
return (
<Tooltip label={label}>
<button
type="button"
onClick={() => askAbout(place.id)}
aria-label={`${label}: ${place.name}`}
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content"
>
<Sparkles size={15} strokeWidth={2.2} aria-hidden="true" />
</button>
</Tooltip>
);
}
@@ -1,350 +0,0 @@
/**
* FE-AI-CARD-001 to FE-AI-CARD-013
*
* The change card: what it asks before anything is written, which decisions
* one press sends, and what the receipt offers afterwards (the map, undo
* while its window is open, and what a failed undo says). Then what ties it
* to the map: a row lights its pin and the pin its row, the days it touches
* read as a band, the phone hops to the map, and once applied the block can
* be compared before and after and taken back as a whole.
*/
import type { AssistantApplied, AssistantChangeEffect, AssistantChangePreview } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import AssistantChangeCard from './AssistantChangeCard';
import AssistantCompareSwitch from './AssistantCompareSwitch';
import type { ChangeItem } from './assistantParts';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
const { addToast } = vi.hoisted(() => ({ addToast: vi.fn() }));
// The toasts a component raises, read as (message, type) the way the toast host takes them.
vi.mock('../shared/Toast', async (importOriginal) => ({
...(await importOriginal<typeof import('../shared/Toast')>()),
useToast: () => ({
success: (message: string) => addToast(message, 'success', undefined),
error: (message: string) => addToast(message, 'error', undefined),
warning: (message: string) => addToast(message, 'warning', undefined),
info: (message: string) => addToast(message, 'info', undefined),
}),
}));
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const TRIP_INITIAL = useTripStore.getState();
const PANTHEON: AssistantChangeEffect = {
kind: 'add',
dayId: 3,
fromDayId: null,
placeId: null,
assignmentId: null,
place: { name: 'Pantheon', lat: 48.02, lng: 2.0, categoryId: null },
};
function preview(toolCallId: string, subject: string): AssistantChangePreview {
return {
toolCallId,
verbKey: 'addPlace',
subject,
rows: [
{ op: 'add', labelKey: 'address', before: null, after: 'Kyoto' },
{ op: 'change', labelKey: 'time', before: '09:00', after: '10:30' },
],
dayId: 3,
placeIds: [],
undoable: true,
};
}
const pending = (id: string, subject: string): ChangeItem => ({
toolCallId: id,
preview: preview(id, subject),
applied: null,
state: 'pending',
});
const onTheMap = (id: string, subject: string, effect = PANTHEON): ChangeItem => ({
...pending(id, subject),
preview: { ...preview(id, subject), effect },
});
function receipt(id: string, undoUntil: string | null): ChangeItem {
const applied: AssistantApplied = { toolCallId: id, entity: 'place', ids: [9], dayId: 3, undoUntil };
return { toolCallId: id, preview: preview(id, 'Kinkaku-ji'), applied, state: 'applied' };
}
function renderCard(items: ChangeItem[], view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
return render(
<AssistantViewContext.Provider value={value}>
<AssistantChangeCard items={items} />
</AssistantViewContext.Provider>
);
}
function patchChat(next: Partial<ReturnType<typeof chatOf>>) {
useAssistantStore.setState((s) => ({ byTrip: { ...s.byTrip, [TRIP]: { ...chatOf(s, TRIP), ...next } } }));
}
/** The planner holding this trip, with one stop on day 3. */
function plannerHere(tripId = TRIP) {
const louvre = buildPlace({ id: 10, name: 'Louvre', lat: 48.0, lng: 2.0 });
useTripStore.setState({
trip: buildTrip({ id: tripId }),
days: [buildDay({ id: 3, title: 'Rome' })],
assignments: { 3: [buildAssignment({ id: 100, day_id: 3, place: louvre, assignment_time: '09:00' })] },
places: [louvre],
});
}
const future = () => new Date(Date.now() + 10 * 60_000).toISOString();
const decide = vi.fn<(tripId: number, decisions: { toolCallId: string; approved: boolean }[]) => Promise<void>>();
const undo = vi.fn<(tripId: number, toolCallId: string) => Promise<'undone' | 'stale' | 'failed'>>();
const undoBlock =
vi.fn<(tripId: number, ids: readonly string[]) => Promise<{ done: number; stale: number; failed: number }>>();
const setCompare = vi.fn<(tripId: number, mode: 'before' | 'after') => void>();
const requestCamera = vi.fn();
const setMinimized = vi.fn();
beforeEach(() => {
decide.mockResolvedValue(undefined);
useAssistantStore.setState({ decide, undo, undoBlock, setCompare, requestCamera, setMinimized });
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
useTripStore.setState(TRIP_INITIAL, true);
vi.clearAllMocks();
vi.useRealTimers();
});
describe('AssistantChangeCard: deciding', () => {
it('FE-AI-CARD-001: shows what changes in plain words, field by field, before anything is written', () => {
renderCard([pending('c1', 'Kinkaku-ji')]);
expect(screen.getByText('Kinkaku-ji')).toBeInTheDocument();
expect(screen.getByText('Add place')).toBeInTheDocument();
// The faint line says when, old to new; the address is for the planner, not the card.
expect(screen.getByText('09:00')).toBeInTheDocument();
expect(screen.getByText('10:30')).toBeInTheDocument();
expect(screen.queryByText('Kyoto')).toBeNull();
expect(screen.queryByRole('button', { name: 'Include this change' })).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('1 change is waiting for you');
});
it('FE-AI-CARD-002: one change applies or discards with one press', () => {
renderCard([pending('c1', 'Kinkaku-ji')]);
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(decide).toHaveBeenLastCalledWith(TRIP, [{ toolCallId: 'c1', approved: true }]);
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(decide).toHaveBeenLastCalledWith(TRIP, [{ toolCallId: 'c1', approved: false }]);
});
it('FE-AI-CARD-003: several changes each get a switch, and the button applies the ones left on in one request', () => {
renderCard([pending('c1', 'Kinkaku-ji'), pending('c2', 'Ryoan-ji'), pending('c3', 'Nanzen-ji')]);
expect(screen.getByRole('button', { name: 'Apply 3' })).toBeEnabled();
const [, second] = screen.getAllByRole('button', { name: 'Include this change' });
fireEvent.click(second as HTMLElement);
fireEvent.click(screen.getByRole('button', { name: 'Apply 2' }));
expect(decide).toHaveBeenCalledTimes(1);
expect(decide).toHaveBeenCalledWith(TRIP, [
{ toolCallId: 'c1', approved: true },
{ toolCallId: 'c2', approved: false },
{ toolCallId: 'c3', approved: true },
]);
});
it('FE-AI-CARD-004: nothing can be decided while the assistant is still answering', () => {
patchChat({ isRunning: true });
renderCard([pending('c1', 'Kinkaku-ji')]);
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Discard' })).toBeDisabled();
});
});
describe('AssistantChangeCard: the receipt', () => {
it('FE-AI-CARD-005: an applied change shows on the map and can be undone until its window closes', () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date('2026-10-21T08:00:00.000Z'));
const onShowOnMap = vi.fn();
renderCard([receipt('c1', '2026-10-21T08:15:00.000Z')], { onShowOnMap });
expect(screen.getByRole('img', { name: 'Applied' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
expect(onShowOnMap).toHaveBeenCalledWith(expect.objectContaining({ toolCallId: 'c1', dayId: 3 }));
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(16 * 60_000);
});
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
});
it('FE-AI-CARD-006: an undone change says so, a stale one explains why it stays', async () => {
// The store marks what it took back; the card reads it from there.
undo.mockImplementationOnce(async (_tripId, toolCallId) => {
patchChat({ undone: { [toolCallId]: true } });
return 'undone';
});
const first = renderCard([receipt('c1', future())]);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
});
expect(undo).toHaveBeenCalledWith(TRIP, 'c1');
expect(screen.getByRole('img', { name: 'Undone' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
first.unmount();
undo.mockResolvedValueOnce('stale');
renderCard([receipt('c2', future())]);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
});
expect(addToast).toHaveBeenCalledWith('Changed in the meantime. Please adjust it by hand.', 'warning', undefined);
});
it('FE-AI-CARD-007: no map button where nothing can show it, and none of the receipt actions after a discard', () => {
renderCard([receipt('c1', null), { ...pending('c2', 'Ryoan-ji'), state: 'discarded' }]);
expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
expect(screen.getByRole('img', { name: 'Discarded' })).toBeInTheDocument();
});
});
describe('AssistantChangeCard: on the map', () => {
it('FE-AI-CARD-008: resting on a row lights its pin, and a lit pin lights its row', () => {
renderCard([onTheMap('c1', 'Pantheon'), pending('c2', 'Ryoan-ji')]);
fireEvent.mouseEnter(screen.getByText('Pantheon'));
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'c1', source: 'card' });
fireEvent.mouseLeave(screen.getByText('Pantheon'));
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
// A change the plan does not show has no pin to light.
fireEvent.mouseEnter(screen.getByText('Ryoan-ji'));
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
const row = screen.getByText('Pantheon').closest('[class*="rounded-[12px]"]');
expect(row).not.toBeNull();
expect(row).not.toHaveClass('bg-surface-hover');
act(() => useAssistantStore.setState({ mapLink: { tripId: TRIP, hoveredId: 'c1', source: 'map' } }));
expect(row).toHaveClass('bg-surface-hover');
});
it('FE-AI-CARD-009: the head says whether the days still fit, without drawing the days again', () => {
plannerHere();
renderCard([onTheMap('c1', 'Pantheon')]);
expect(screen.getByText(/Fits the time|to check/)).toBeInTheDocument();
// The day list of the planner shows the suggestion; the card keeps to one line per change.
expect(screen.getAllByText('Pantheon')).toHaveLength(1);
expect(screen.queryByRole('button', { name: 'Show schedule' })).toBeNull();
});
it('FE-AI-CARD-010: no check while the planner holds another trip, or once nothing waits', () => {
plannerHere(TRIP + 1);
const first = renderCard([onTheMap('c1', 'Pantheon')]);
expect(screen.queryByText(/Fits the time|to check/)).toBeNull();
first.unmount();
plannerHere();
renderCard([{ ...onTheMap('c1', 'Pantheon'), state: 'applied' }]);
expect(screen.queryByText(/Fits the time|to check/)).toBeNull();
});
it('FE-AI-CARD-011: on the phone the card hops to the map and frames what it would change', () => {
plannerHere();
const onShowOnMap = vi.fn();
const first = renderCard([onTheMap('c1', 'Pantheon')], { onShowOnMap });
expect(screen.queryByRole('button', { name: 'View on map' })).toBeNull();
first.unmount();
renderCard([onTheMap('c1', 'Pantheon')], { variant: 'fullscreen', onShowOnMap });
fireEvent.click(screen.getByRole('button', { name: 'View on map' }));
expect(requestCamera).toHaveBeenCalledWith(
TRIP,
[
[48.02, 2.0],
[48.0, 2.0],
],
'fit',
'user'
);
expect(setMinimized).toHaveBeenCalledWith(true);
});
});
describe('AssistantChangeCard: the applied block', () => {
const compare = (blockKey: string, mode: 'before' | 'after' = 'after') =>
patchChat({ compare: { blockKey, mode, snapshot: { 3: [] }, newPlaceCalls: [], addedPlaceIds: [] } });
it('FE-AI-CARD-012: before and after are there only for the block this session can compare, beside a map', () => {
compare('c1');
const onShowOnMap = vi.fn();
const first = renderCard([receipt('c1', null), receipt('c2', null)], { onShowOnMap });
expect(screen.getByRole('button', { name: 'After' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Before' }));
expect(setCompare).toHaveBeenCalledWith(TRIP, 'before');
first.unmount();
const other = renderCard([receipt('c7', null), receipt('c8', null)], { onShowOnMap });
expect(screen.queryByRole('button', { name: 'Before' })).toBeNull();
other.unmount();
renderCard([receipt('c1', null), receipt('c2', null)]);
expect(screen.queryByRole('button', { name: 'Before' })).toBeNull();
});
it('FE-AI-CARD-013: undo all takes back what still can be, and says how it went', async () => {
const ids = ['c1', 'c2', 'c3'];
const items = () => [receipt('c1', future()), receipt('c2', future()), receipt('c3', null)];
const press = async () => {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Undo all' }));
});
};
undoBlock.mockResolvedValueOnce({ done: 2, stale: 0, failed: 0 });
const first = renderCard(items());
await press();
expect(undoBlock).toHaveBeenCalledWith(TRIP, ids.slice(0, 2));
expect(addToast).toHaveBeenLastCalledWith('2 changes undone', 'success', undefined);
undoBlock.mockResolvedValueOnce({ done: 1, stale: 1, failed: 0 });
await press();
expect(addToast).toHaveBeenLastCalledWith('1 undone, 1 changed since', 'warning', undefined);
undoBlock.mockResolvedValueOnce({ done: 0, stale: 0, failed: 2 });
await press();
expect(addToast).toHaveBeenLastCalledWith('2 changes could not be undone', 'error', undefined);
first.unmount();
// One left to take back is the single undo button's job.
patchChat({ undone: { c2: true } });
renderCard(items());
expect(screen.queryByRole('button', { name: 'Undo all' })).toBeNull();
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
});
});
describe('AssistantCompareSwitch', () => {
it('follows whichever block is compared when it names none', () => {
patchChat({ compare: { blockKey: 'c5', mode: 'before', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] } });
render(<AssistantCompareSwitch tripId={TRIP} />);
expect(screen.getByRole('button', { name: 'Before' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: 'After' }));
expect(setCompare).toHaveBeenCalledWith(TRIP, 'after');
});
it("gives each side a thumb's room when tall", () => {
patchChat({ compare: { blockKey: 'c5', mode: 'after', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] } });
render(<AssistantCompareSwitch tripId={TRIP} tall />);
expect(screen.getByRole('button', { name: 'Before' }).className).toContain('h-9');
expect(screen.getByRole('button', { name: 'After' }).className).toContain('min-w-[44px]');
});
});
@@ -1,541 +0,0 @@
import type { AssistantApplied, AssistantChangePreview, AssistantChangeVerbKey } from '@trek/shared';
import {
CalendarArrowUp,
CalendarMinus,
CalendarPlus,
Check,
CircleSlash,
ListTodo,
LoaderCircle,
Luggage,
MapPin,
MapPinPlus,
NotebookPen,
PencilLine,
Plus,
StickyNote,
Ticket,
Trash2,
TriangleAlert,
Undo2,
Wallet,
} from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { blockKeyOf } from '../../store/assistantMapModel';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import { useToast } from '../shared/Toast';
import { Tooltip } from '../shared/Tooltip';
import AssistantChangeFields from './AssistantChangeFields';
import AssistantCompareSwitch from './AssistantCompareSwitch';
import AssistantFitCheck from './AssistantFitCheck';
import AssistantWeekOverview from './AssistantWeekOverview';
import { type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { changePoints, type DayChange } from './dayAfterModel';
import { useChangeHover } from './useChangeHover';
import { useWeekDays } from './useWeekDays';
/**
* The changes the assistant proposes in one go, as one card in plain words:
* what happens to which place, note or item, field by field. Nothing is
* written before the user applies it. Each change keeps its own decision, so
* when the card holds several, each has a switch and the button applies the
* ones left on. Once decided, the card turns into a receipt per change, with
* a way to the change on the map and, for a while, undo.
*
* Every change wears its kind: a tinted tile with the icon of what it touches
* and a badge naming the verb, in the tone of what happens (green adds, blue
* changes, amber moves, red takes away). Its fields sit as badges holding a
* label badge, so a row of them reads at a glance.
*
* While it waits, the card shows the days it touches as they would read
* afterwards, and each row lights its pin on the map (and the pin its row).
* Once applied, the head offers the plan before and after the block on the
* map, and taking the whole block back at once.
*/
type Tone = 'success' | 'info' | 'warning' | 'danger';
const TILE: Record<Tone, string> = {
success: 'bg-success-soft text-success',
info: 'bg-info-soft text-info',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
};
/** What each kind of change looks like. A table, so a new verb fails to compile until it has a look. */
const VERB_LOOK: Record<AssistantChangeVerbKey, { Icon: typeof Check; tone: Tone }> = {
addPlace: { Icon: MapPinPlus, tone: 'success' },
addPlaceToDay: { Icon: CalendarPlus, tone: 'success' },
updatePlace: { Icon: PencilLine, tone: 'info' },
assignPlace: { Icon: CalendarPlus, tone: 'success' },
moveAssignment: { Icon: CalendarArrowUp, tone: 'warning' },
removeFromDay: { Icon: CalendarMinus, tone: 'danger' },
addDayNote: { Icon: StickyNote, tone: 'success' },
updateDayNote: { Icon: StickyNote, tone: 'info' },
addPackingItem: { Icon: Luggage, tone: 'success' },
updatePackingItem: { Icon: Luggage, tone: 'info' },
addTodo: { Icon: ListTodo, tone: 'success' },
addBudgetItem: { Icon: Wallet, tone: 'success' },
updateBudgetItem: { Icon: Wallet, tone: 'info' },
addReservation: { Icon: Ticket, tone: 'success' },
importBooking: { Icon: Ticket, tone: 'success' },
rememberNote: { Icon: NotebookPen, tone: 'info' },
genericCreate: { Icon: Plus, tone: 'success' },
genericUpdate: { Icon: PencilLine, tone: 'info' },
genericRemove: { Icon: Trash2, tone: 'danger' },
};
/** How a decided change reads: one quiet mark, its words in the tooltip. */
const RECEIPT: Record<Exclude<ChangeState, 'pending'>, { labelKey: string; Icon: typeof Check; tone: string }> = {
applying: { labelKey: 'assistant.change.applying', Icon: LoaderCircle, tone: 'text-content-muted' },
applied: { labelKey: 'assistant.change.applied', Icon: Check, tone: 'text-success' },
failed: { labelKey: 'assistant.change.failed', Icon: TriangleAlert, tone: 'text-danger' },
discarded: { labelKey: 'assistant.change.discarded', Icon: CircleSlash, tone: 'text-content-faint' },
expired: { labelKey: 'assistant.change.expired', Icon: CircleSlash, tone: 'text-content-faint' },
};
const ICON_BUTTON =
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-50';
function KindTile({
verbKey,
size = 'md',
dimmed = false,
}: {
verbKey: AssistantChangeVerbKey | null;
size?: 'sm' | 'md';
dimmed?: boolean;
}) {
const box = size === 'sm' ? 'h-7 w-7 rounded-md' : 'h-8 w-8 rounded-lg';
if (!verbKey) return <span className={`trek-skeleton flex-shrink-0 ${box}`} aria-hidden="true" />;
const { Icon, tone } = VERB_LOOK[verbKey];
return (
<span
aria-hidden="true"
className={`flex flex-shrink-0 items-center justify-center ${box} ${dimmed ? 'bg-surface-secondary text-content-faint' : TILE[tone]}`}
>
<Icon size={size === 'sm' ? 14 : 16} strokeWidth={2.1} />
</span>
);
}
/** The key of a tool's own action name, for the tools TREK names one for. */
const toolKey = (tool: string) => `assistant.tool.${tool}`;
/** What the change does: the tool's own action where TREK names it, else add, change or remove. */
function verbLabel(preview: AssistantChangePreview, t: (key: string) => string): string {
const own = preview.tool ? toolKey(preview.tool) : null;
const named = own ? t(own) : null;
return named && named !== own ? named : t(`assistant.change.verb.${preview.verbKey}`);
}
function ChangeHead({ preview, struck = false }: { preview: AssistantChangePreview | null; struck?: boolean }) {
const { t } = useTranslation();
if (!preview) return <span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />;
return (
<span className="flex min-w-0 flex-col">
<bdi className={`truncate text-body font-semibold leading-tight text-content ${struck ? 'line-through' : ''}`}>
{preview.subject}
</bdi>
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
<span className="truncate">{verbLabel(preview, t)}</span>
{preview.domain && (
// Where in TREK a change outside the trip plan lands: the vacation planner, the journal.
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
{t(`assistant.domain.${preview.domain}`)}
</span>
)}
</span>
</span>
);
}
/** The time now, ticking once more when the next undo window closes. */
function useUndoClock(items: ChangeItem[]): number {
const [now, setNow] = useState(() => Date.now());
const next = Math.min(...items.map((i) => undoLeftMs(i.applied, now)).filter((ms) => ms > 0));
useEffect(() => {
if (!Number.isFinite(next)) return;
const timer = window.setTimeout(() => setNow(Date.now()), next + 50);
return () => window.clearTimeout(timer);
}, [next]);
return now;
}
/** Whether an applied change can still be taken back now. */
function undoableNow(item: ChangeItem, undone: Readonly<Record<string, true>>, now: number): boolean {
return (
item.state === 'applied' &&
!undone[item.toolCallId] &&
!!item.preview?.undoable &&
undoLeftMs(item.applied, now) > 0
);
}
/** The waiting changes the user left on that touch the day plan. */
function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): DayChange[] {
return items.flatMap((i) =>
i.state === 'pending' && i.preview?.effect && !excluded.has(i.toolCallId)
? [{ toolCallId: i.toolCallId, effect: i.preview.effect, subject: i.preview.subject }]
: []
);
}
/** What an applied change can be shown as on the map; a kept note, a todo or an expense has no place there. */
const ON_MAP: ReadonlySet<AssistantApplied['entity']> = new Set(['place', 'assignment', 'day_note', 'reservation']);
function Receipt({
item,
undone,
canUndo,
onUndo,
}: {
item: ChangeItem;
undone: boolean;
canUndo: boolean;
onUndo: () => void;
}) {
const { t } = useTranslation();
const { onShowOnMap } = useAssistantView();
if (item.state === 'pending') return null;
const receipt = undone ? RECEIPT.discarded : RECEIPT[item.state];
const { Icon } = receipt;
const applied = item.state === 'applied' && !undone ? item.applied : null;
const mappable = applied !== null && ON_MAP.has(applied.entity);
const settled = item.state === 'discarded' || item.state === 'expired' || undone;
const statusLabel = t(undone ? 'assistant.change.undone' : receipt.labelKey);
return (
<div className="trek-assistant-enter flex items-center gap-2.5 py-1.5">
<KindTile verbKey={item.preview?.verbKey ?? null} size="sm" dimmed={settled} />
<div className={`min-w-0 flex-1 ${settled ? 'opacity-60' : ''}`}>
<ChangeHead preview={item.preview} struck={settled} />
</div>
{applied && mappable && onShowOnMap && (
<Tooltip label={t('assistant.change.showOnMap')} placement="top">
<button
type="button"
onClick={() => onShowOnMap(applied)}
aria-label={t('assistant.change.showOnMap')}
className={ICON_BUTTON}
>
<MapPin size={14} aria-hidden="true" />
</button>
</Tooltip>
)}
{canUndo && (
<Tooltip label={t('assistant.change.undo')} placement="top">
<button type="button" onClick={onUndo} aria-label={t('assistant.change.undo')} className={ICON_BUTTON}>
<Undo2 size={14} aria-hidden="true" />
</button>
</Tooltip>
)}
<Tooltip label={statusLabel} placement="top">
<span
role="img"
aria-label={statusLabel}
className={`flex h-7 w-5 items-center justify-center ${receipt.tone}`}
>
<Icon size={15} strokeWidth={2.4} className={item.state === 'applying' ? 'animate-spin' : ''} />
</span>
</Tooltip>
</div>
);
}
/**
* A change waiting for its decision, as a row of the planner's day list: the
* round choice in front (only when there is more than one to choose from),
* what it touches in bold, and a faint line saying what happens and when.
* Resting on it lights its pin on the map.
*/
function PendingRow({
item,
excluded,
withChoice,
disabled,
onToggle,
}: {
item: ChangeItem;
excluded: boolean;
withChoice: boolean;
disabled: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const { litFromMap, handlers } = useChangeHover(tripId, item.preview?.effect ? item.toolCallId : null);
const preview = item.preview;
return (
<div
{...handlers}
className={`-mx-1.5 flex items-center gap-2.5 rounded-[12px] px-1.5 py-2 transition-[opacity,background-color] ${
excluded ? 'opacity-50' : ''
} ${litFromMap ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
>
{withChoice && (
<button
type="button"
onClick={onToggle}
disabled={disabled}
aria-pressed={!excluded}
aria-label={t('assistant.change.include')}
className={`grid h-[18px] w-[18px] flex-none place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 ${
excluded ? 'border-[1.5px] border-edge bg-surface-card' : 'bg-accent'
}`}
>
{!excluded && <Check size={11} strokeWidth={3} aria-hidden="true" className="text-accent-text" />}
</button>
)}
<KindTile verbKey={preview?.verbKey ?? null} size="sm" dimmed={excluded} />
<div className="min-w-0 flex-1">
{preview ? (
<>
<bdi className="block truncate text-body font-semibold leading-tight text-content">{preview.subject}</bdi>
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
<span className="flex-shrink-0">{verbLabel(preview, t)}</span>
{preview.domain && (
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
{t(`assistant.domain.${preview.domain}`)}
</span>
)}
<AssistantChangeFields rows={preview.rows} />
</span>
</>
) : (
<span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />
)}
</div>
</div>
);
}
/** The card's head: what the card is, what can be done with it as a whole, and how many changes it holds. */
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
const { t } = useTranslation();
return (
<div className="mb-1 flex min-h-7 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-content-secondary">
{t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
</span>
{actions}
<span className="text-caption tabular-nums text-content-faint">{count}</span>
</div>
);
}
/** On the phone the map hides behind the chat: this shrinks the chat and frames what the card would change. */
function ViewOnMapButton({ changes }: { changes: readonly DayChange[] }) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const requestCamera = useAssistantStore((s) => s.requestCamera);
const setMinimized = useAssistantStore((s) => s.setMinimized);
const show = () => {
const { assignments, places } = useTripStore.getState();
requestCamera(tripId, changePoints(changes, assignments, places), 'fit', 'user');
setMinimized(true);
};
return (
<Tooltip label={t('assistant.change.viewOnMap')} placement="top">
<button type="button" onClick={show} aria-label={t('assistant.change.viewOnMap')} className={ICON_BUTTON}>
<MapPin size={14} aria-hidden="true" />
</button>
</Tooltip>
);
}
/** Takes back every change of the block that still can be, the last one first, and says how it went. */
function UndoAllButton({ toolCallIds }: { toolCallIds: readonly string[] }) {
const { t } = useTranslation();
const toast = useToast();
const { tripId } = useAssistantView();
const undoBlock = useAssistantStore((s) => s.undoBlock);
const [busy, setBusy] = useState(false);
const run = async () => {
setBusy(true);
try {
const { done, stale, failed } = await undoBlock(tripId, toolCallIds);
if (failed > 0) toast.error(t('assistant.change.undoAllFailed', { count: failed }));
else if (stale > 0) toast.warning(t('assistant.change.undoAllPartial', { done, stale }));
else toast.success(t('assistant.change.undoneAll', { count: done }));
} finally {
setBusy(false);
}
};
return (
<Tooltip label={t('assistant.change.undoAll')} placement="top">
<button
type="button"
disabled={busy}
onClick={() => void run()}
aria-label={t('assistant.change.undoAll')}
className={ICON_BUTTON}
>
{busy ? (
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
) : (
<Undo2 size={14} aria-hidden="true" />
)}
</button>
</Tooltip>
);
}
/** What the head offers: on a waiting card the way to the map on the phone, on a receipt the block as a whole. */
function HeadActions({
open,
items,
changes,
undoable,
}: {
open: boolean;
items: readonly ChangeItem[];
changes: readonly DayChange[];
undoable: readonly string[];
}) {
const { tripId, variant, onShowOnMap } = useAssistantView();
const here = useTripStore((s) => s.trip?.id === tripId);
if (open) {
const toMap = variant === 'fullscreen' && !!onShowOnMap && here && changes.length > 0;
return (
<>
<AssistantFitCheck changes={changes} />
{toMap && <ViewOnMapButton changes={changes} />}
</>
);
}
return (
<>
{onShowOnMap && <AssistantCompareSwitch tripId={tripId} blockKey={blockKeyOf(items.map((i) => i.toolCallId))} />}
{undoable.length >= 2 && <UndoAllButton toolCallIds={undoable} />}
</>
);
}
export default function AssistantChangeCard({ items }: { items: ChangeItem[] }) {
const { t } = useTranslation();
const toast = useToast();
const { tripId } = useAssistantView();
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
const undone = useAssistantStore((s) => chatOf(s, tripId).undone);
const decide = useAssistantStore((s) => s.decide);
const undo = useAssistantStore((s) => s.undo);
const [excluded, setExcluded] = useState<ReadonlySet<string>>(() => new Set());
const now = useUndoClock(items);
const pending = items.filter((i) => i.state === 'pending');
const chosen = pending.filter((i) => !excluded.has(i.toolCallId)).length;
const changes = useMemo(() => dayChangesOf(items, excluded), [items, excluded]);
const undoable = items.filter((i) => undoableNow(i, undone, now)).map((i) => i.toolCallId);
const week = useWeekDays(pending);
const [showAll, setShowAll] = useState(false);
// A plan over a week reads from its grid; the single changes wait behind one line.
const folded = week.length > 0 && !showAll;
const toggleDay = (ids: readonly string[], include: boolean) =>
setExcluded((prev) => {
const next = new Set(prev);
for (const id of ids) {
if (include) next.delete(id);
else next.add(id);
}
return next;
});
const toggle = (toolCallId: string) =>
setExcluded((prev) => {
const next = new Set(prev);
if (!next.delete(toolCallId)) next.add(toolCallId);
return next;
});
const submit = (applyChosen: boolean) =>
decide(
tripId,
pending.map((i) => ({ toolCallId: i.toolCallId, approved: applyChosen && !excluded.has(i.toolCallId) }))
);
// An undone change reads as undone from the store; only what stays needs a word.
const runUndo = async (toolCallId: string) => {
const outcome = await undo(tripId, toolCallId);
if (outcome === 'stale') toast.warning(t('assistant.change.undoStale'));
else if (outcome === 'failed') toast.error(t('assistant.change.undoFailed'));
};
const open = pending.length > 0;
return (
<section className="trek-popover-enter rounded-xl border border-edge-faint bg-surface-card px-3 pb-2 pt-2.5 shadow-card">
<CardHead
open={open}
count={items.length}
actions={<HeadActions open={open} items={items} changes={changes} undoable={undoable} />}
/>
{open && week.length > 0 && (
<AssistantWeekOverview days={week} excluded={excluded} disabled={isRunning} onToggleDay={toggleDay} />
)}
{folded && (
<button
type="button"
onClick={() => setShowAll(true)}
className="mb-1 w-full rounded-lg px-2 py-1.5 text-center text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
{t('assistant.week.showAll', { count: pending.length })}
</button>
)}
<div>
{items.map((item) =>
item.state === 'pending' ? (
folded ? null : (
<PendingRow
key={item.toolCallId}
item={item}
excluded={excluded.has(item.toolCallId)}
withChoice={pending.length > 1}
disabled={isRunning}
onToggle={() => toggle(item.toolCallId)}
/>
)
) : (
<Receipt
key={item.toolCallId}
item={item}
undone={!!undone[item.toolCallId]}
canUndo={undoable.includes(item.toolCallId)}
onUndo={() => void runUndo(item.toolCallId)}
/>
)
)}
</div>
{open && (
<>
<p className="sr-only" role="status">
{t('assistant.change.pending', { count: pending.length })}
</p>
<div className="flex justify-end gap-2 border-t border-edge-faint pt-2.5">
<button
type="button"
disabled={isRunning}
onClick={() => void submit(false)}
className="rounded-lg px-3 py-1.5 text-body text-content-secondary hover:bg-surface-hover hover:text-content disabled:opacity-50"
>
{t('assistant.change.discard')}
</button>
<button
type="button"
disabled={isRunning || chosen === 0}
onClick={() => void submit(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-body font-medium text-accent-text hover:bg-accent-hover disabled:opacity-50"
>
<Check size={14} aria-hidden="true" />
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
</button>
</div>
</>
)}
</section>
);
}
@@ -1,75 +0,0 @@
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
import {
ArrowRight,
CalendarClock,
CalendarDays,
Clock,
Hash,
ListOrdered,
LogIn,
LogOut,
type LucideIcon,
Tag,
Wallet,
} from 'lucide-react';
import { useTranslation } from '../../i18n';
/**
* The few facts that tell a change apart at a glance, on the faint line under
* its name the way the planner's day list shows a place: when, what kind,
* what it costs, a new order. A value that changes reads old to new. The rest
* of what the change sets is not listed: the planner shows it once applied.
*/
/** The fields worth a place on the line, each with its glyph, in this order. */
const KEY_FIELDS: Partial<Record<AssistantChangeLabelKey, LucideIcon>> = {
day: CalendarDays,
time: Clock,
endTime: Clock,
checkIn: LogIn,
checkOut: LogOut,
dueDate: CalendarClock,
category: Tag,
price: Wallet,
quantity: Hash,
order: ListOrdered,
};
const KEY_ORDER = Object.keys(KEY_FIELDS) as AssistantChangeLabelKey[];
const MAX_FACTS = 3;
/** "#3" is how a day comes from the server; it reads as "Day 3". */
function shown(row: AssistantChangeRow, value: string | null | undefined, dayWord: string): string {
if (row.labelKey === 'day' && value && /^#\d+$/.test(value)) return `${dayWord} ${value.slice(1)}`;
return value ?? '';
}
export default function AssistantChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
const { t } = useTranslation();
const dayWord = t('assistant.week.day');
const facts = rows
.filter((row) => row.labelKey in KEY_FIELDS && row.after)
.sort((a, b) => KEY_ORDER.indexOf(a.labelKey) - KEY_ORDER.indexOf(b.labelKey))
.slice(0, MAX_FACTS);
if (facts.length === 0) return null;
return (
<span className="flex min-w-0 items-center gap-2.5">
{facts.map((row, i) => {
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
const changed = row.op !== 'add' && !!row.before;
return (
<span key={`${row.labelKey}-${i}`} className="inline-flex min-w-0 items-center gap-1">
<Icon size={11} aria-hidden="true" className="flex-shrink-0" />
{changed && (
<>
<bdi className="truncate line-through">{shown(row, row.before, dayWord)}</bdi>
<ArrowRight size={10} aria-hidden="true" className="flex-shrink-0 rtl:-scale-x-100" />
</>
)}
<bdi className="truncate text-content-muted">{shown(row, row.after, dayWord)}</bdi>
</span>
);
})}
</span>
);
}
@@ -1,58 +0,0 @@
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { Tooltip } from '../shared/Tooltip';
/**
* "Before" and "after" of the block of changes applied last, as a small
* segmented switch: "before" lays the days it touched back the way they were
* on the map, "after" shows the plan as it is. Only there while this session
* holds the plan from before the block ran; a thread opened after a reload has
* no switch. Without a `blockKey` it follows whichever block is compared.
* `tall` gives each side a thumb's room, for the phone, where a tooltip
* would never show.
*/
const MODES = [
{ mode: 'before', labelKey: 'assistant.change.before' },
{ mode: 'after', labelKey: 'assistant.change.after' },
] as const;
interface AssistantCompareSwitchProps {
tripId: number;
blockKey?: string;
tall?: boolean;
}
export default function AssistantCompareSwitch({ tripId, blockKey, tall = false }: AssistantCompareSwitchProps) {
const { t } = useTranslation();
const current = useAssistantStore((s) => {
const compare = chatOf(s, tripId).compare;
return compare && (blockKey === undefined || compare.blockKey === blockKey) ? compare.mode : null;
});
const setCompare = useAssistantStore((s) => s.setCompare);
if (!current) return null;
const label = t('assistant.change.compare');
const group = (
<div role="group" aria-label={label} className="inline-flex flex-shrink-0 rounded-lg bg-surface-secondary p-0.5">
{MODES.map(({ mode, labelKey }) => (
<button
key={mode}
type="button"
aria-pressed={current === mode}
onClick={() => setCompare(tripId, mode)}
className={`rounded-md text-caption font-medium transition-colors ${
tall ? 'h-9 min-w-[44px] px-3' : 'px-2 py-0.5'
} ${current === mode ? 'bg-surface-card text-content shadow-card' : 'text-content-muted hover:text-content'}`}
>
{t(labelKey)}
</button>
))}
</div>
);
if (tall) return group;
return (
<Tooltip label={label} placement="top">
{group}
</Tooltip>
);
}
@@ -1,165 +0,0 @@
/**
* FE-AI-MENTION-001 to FE-AI-MENTION-007
*
* `@` in the composer on the real @assistant-ui runtime: the list of the
* trip's days, places and bookings opens only at the start of a word, Enter
* picks from it instead of sending, Escape closes it and keeps the text, and
* a pick leaves `@label` in the text, with no chip above it: the text is what
* the question names.
*/
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildDay, buildPlace, buildReservation, buildTrip } from '../../../tests/helpers/factories';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import AssistantSurface from './AssistantSurface';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const INITIAL_TRIP = useTripStore.getState();
const STATUS: AiStatusResponse = {
available: true,
policy: 'instance',
canConnectOwn: false,
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
own: null,
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
quota: null,
privacyAcked: true,
retention: { mode: 'days', days: 90 },
openrouterOAuth: false,
};
const send = vi.fn(async () => {});
function seed(tripInPlanner = TRIP) {
useAssistantStore.setState({
status: STATUS,
send,
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages: [] } },
});
useTripStore.setState({
trip: buildTrip({ id: tripInPlanner, title: 'Paris' }),
days: [buildDay({ id: 11, title: 'Arrival', date: '2026-11-02' }), buildDay({ id: 12, title: null, date: null })],
places: [buildPlace({ id: 21, name: 'Louvre' }), buildPlace({ id: 22, name: 'Bouillon Pigalle' })],
reservations: [buildReservation({ id: 31, title: 'Dinner at Le Train Bleu', day_id: 11 })],
});
}
async function input(): Promise<HTMLTextAreaElement> {
render(<AssistantSurface tripId={TRIP} tripTitle="Paris" variant="window" />);
return (await screen.findByRole('textbox', { name: 'Ask anything about Paris' })) as HTMLTextAreaElement;
}
const mentions = () => chatOf(useAssistantStore.getState(), TRIP).mentions;
beforeAll(() => {
// jsdom lays nothing out and has no element scrolling; the viewport only needs the call to exist.
Element.prototype.scrollTo = vi.fn() as unknown as typeof Element.prototype.scrollTo;
});
beforeEach(() => send.mockClear());
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
useTripStore.setState(INITIAL_TRIP, true);
});
describe('AssistantComposer mentions', () => {
it('FE-AI-MENTION-001: typing after @ lists matches, and Enter picks one instead of sending', async () => {
seed();
const field = await input();
expect(screen.getByText('Type @ to mention days, places and bookings.')).toBeInTheDocument();
fireEvent.change(field, { target: { value: 'Is @lou' } });
expect(await screen.findByRole('option', { name: 'Louvre' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Bouillon Pigalle' })).toBeNull();
fireEvent.keyDown(field, { key: 'Enter' });
await waitFor(() => expect(field.value).toBe('Is @Louvre '));
expect(send).not.toHaveBeenCalled();
// A browser reports where the caret went after the pick; jsdom does not on its own.
fireEvent.select(field);
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
expect(mentions()).toEqual([{ kind: 'place', id: 21, label: 'Louvre' }]);
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Is @Louvre ');
// The pick shows in the text only, never as a chip above it.
expect(screen.queryByRole('button', { name: 'Remove context' })).toBeNull();
fireEvent.change(field, { target: { value: 'Is @Louvre open late?' } });
fireEvent.keyDown(field, { key: 'Enter' });
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Is @Louvre open late?'));
});
it('FE-AI-MENTION-002: a bare @ offers the kinds, and a kind opens its entries', async () => {
seed();
const field = await input();
fireEvent.change(field, { target: { value: '@' } });
expect(await screen.findByRole('option', { name: 'Days' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Places' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Bookings' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('option', { name: 'Days' }));
expect(await screen.findByRole('option', { name: /Arrival/ })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Day 2' })).toBeInTheDocument();
// The heading leads back to the kinds.
fireEvent.click(screen.getByRole('button', { name: 'Days' }));
expect(await screen.findByRole('option', { name: 'Places' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('option', { name: 'Days' }));
fireEvent.click(await screen.findByRole('option', { name: /Arrival/ }));
await waitFor(() => expect(field.value).toBe('@Arrival '));
expect(mentions()).toEqual([{ kind: 'day', id: 11, label: 'Arrival' }]);
});
it('FE-AI-MENTION-003: Escape closes the list and keeps what was typed', async () => {
seed();
const field = await input();
fireEvent.change(field, { target: { value: 'near @bou' } });
expect(await screen.findByRole('listbox')).toBeInTheDocument();
fireEvent.keyDown(field, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
expect(field.value).toBe('near @bou');
expect(mentions()).toEqual([]);
});
it('FE-AI-MENTION-004: @ inside a word, such as an email address, opens nothing', async () => {
seed();
const field = await input();
fireEvent.change(field, { target: { value: 'mail@lou' } });
expect(screen.queryByRole('listbox')).toBeNull();
});
it('FE-AI-MENTION-005: says so when nothing matches', async () => {
seed();
const field = await input();
fireEvent.change(field, { target: { value: '@zzz' } });
expect(await screen.findByText('Nothing found')).toBeInTheDocument();
});
it('FE-AI-MENTION-006: a mention is taken back by deleting its @label, with no chip row to keep in step', async () => {
seed();
const field = await input();
fireEvent.change(field, { target: { value: 'Dinner after @Din' } });
fireEvent.click(await screen.findByRole('option', { name: /Dinner at Le Train Bleu/ }));
await waitFor(() => expect(field.value).toBe('Dinner after @Dinner at Le Train Bleu '));
expect(screen.queryByRole('button', { name: 'Remove context' })).toBeNull();
fireEvent.change(field, { target: { value: 'Dinner after ' } });
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Dinner after ');
expect(screen.queryByText('Dinner at Le Train Bleu')).toBeNull();
});
it('FE-AI-MENTION-007: for a trip the planner does not show, @ stays plain text', async () => {
seed(99);
const field = await input();
fireEvent.change(field, { target: { value: '@lou' } });
expect(screen.queryByRole('listbox')).toBeNull();
expect(screen.queryByText('Type @ to mention days, places and bookings.')).toBeNull();
});
});
@@ -1,185 +0,0 @@
import { AuiIf, ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react';
import { ArrowUp, CalendarDays, MapPin, Paperclip, Square, X } from 'lucide-react';
import { type ReactNode, useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import PlaceAvatar from '../shared/PlaceAvatar';
import Tooltip from '../shared/Tooltip';
import AssistantMentionPopover from './AssistantMentionPopover';
import { useAssistantView } from './assistantViewContext';
import { useAssistantAttach } from './useAssistantAttach';
import { useAssistantFocus } from './useAssistantFocus';
import { useAssistantMentions } from './useAssistantMentions';
import { useAssistantSuggestions } from './useAssistantSuggestions';
import { useTypewriterPlaceholder } from './useTypewriterPlaceholder';
/** Send and Stop share one spot; on the phone it is a full thumb's size. */
const ACTION = {
window: 'h-9 w-9',
fullscreen: 'h-11 w-11',
} as const;
/**
* Where the user writes. One rounded shell with the text and, in the same
* spot, Send or Stop. Enter sends, Shift+Enter breaks the line, Escape stops
* a running answer. The one chip above the text is the focus the question is
* about, and can be removed. `@` at the start of a word opens the trip's days,
* places and bookings to name; a pick stays as `@label` in the text, which is
* what the question names, and shows as a chip only in the sent bubble. While
* that list is open, Enter picks and Escape closes it. While the field is empty,
* its placeholder writes out things worth asking about this trip, one after
* another.
*
* The text typed so far lives in the assistant store too, so it survives the
* window closing or the phone hopping to the map and back.
*/
export default function AssistantComposer({ tripId, tripTitle }: { tripId: number; tripTitle: string }) {
const { t } = useTranslation();
const { variant } = useAssistantView();
const aui = useAui();
const setDraft = useAssistantStore((s) => s.setDraft);
const { label, kind, place, clear } = useAssistantFocus(tripId);
const mentions = useAssistantMentions(tripId);
const { suggestions } = useAssistantSuggestions(tripId);
const empty = useAuiState((s) => s.composer.isEmpty);
const { take, busy, accept } = useAssistantAttach(tripId);
const fileInput = useRef<HTMLInputElement>(null);
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
const placeholder = useTypewriterPlaceholder(
suggestions.map((s) => s.prompt),
fallback,
empty
);
// Takes back what was typed before the chat was last shown.
useEffect(() => {
const draft = chatOf(useAssistantStore.getState(), tripId).draft;
if (draft) aui.composer.setText(draft);
}, [aui, tripId]);
const FocusIcon = kind === 'place' ? MapPin : CalendarDays;
return (
<ComposerPrimitive.Root className="relative rounded-2xl border border-edge-faint bg-surface-card shadow-card transition-colors focus-within:border-edge">
<ComposerPrimitive.TriggerPopoverRoot>
<AssistantMentionPopover adapter={mentions.adapter} onInserted={mentions.onInserted} />
{label && (
<div className="flex flex-wrap gap-1 px-2 pt-2">
<ContextChip
label={label}
icon={place ? <PlaceAvatar place={place} size={18} /> : <FocusIcon size={12} aria-hidden="true" />}
round={!!place}
onRemove={clear}
/>
</div>
)}
<div className="flex items-center gap-1 p-1.5">
<Tooltip label={t('assistant.attach.button')} placement="top">
<button
type="button"
disabled={busy}
onClick={() => fileInput.current?.click()}
aria-label={t('assistant.attach.button')}
className={`flex ${ACTION[variant]} flex-shrink-0 items-center justify-center rounded-full text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40`}
>
<Paperclip size={17} aria-hidden="true" />
</button>
</Tooltip>
<input
ref={fileInput}
type="file"
accept={accept}
hidden
onChange={(e) => {
take(e.target.files);
e.target.value = '';
}}
/>
<ComposerPrimitive.Input
// The desktop window opens ready to type; on the phone the keyboard waits for a tap.
autoFocus={variant === 'window'}
dir="auto"
minRows={1}
maxRows={8}
submitMode="enter"
addAttachmentOnPaste={false}
enterKeyHint="send"
aria-label={fallback}
placeholder={placeholder}
onChange={(e) => setDraft(tripId, e.target.value)}
onPaste={(e) => {
if (e.clipboardData.files.length > 0 && take(e.clipboardData.files)) e.preventDefault();
}}
className="max-h-48 min-h-[36px] flex-1 resize-none bg-transparent px-1.5 py-1.5 text-body text-content outline-none placeholder:text-content-faint disabled:cursor-not-allowed dark:bg-transparent"
/>
<AuiIf condition={(s) => s.thread.isRunning}>
<ComposerPrimitive.Cancel
aria-label={t('assistant.composer.stop')}
className={`flex ${ACTION[variant]} flex-shrink-0 items-center justify-center rounded-full bg-accent text-accent-text transition-transform hover:scale-105 hover:bg-accent-hover active:scale-95`}
>
<Square size={12} fill="currentColor" aria-hidden="true" />
</ComposerPrimitive.Cancel>
</AuiIf>
<AuiIf condition={(s) => !s.thread.isRunning}>
<SendButton size={ACTION[variant]} />
</AuiIf>
</div>
</ComposerPrimitive.TriggerPopoverRoot>
</ComposerPrimitive.Root>
);
}
/** One thing the question is about, above the text, with the cross that takes it back. */
function ContextChip({
label,
icon,
round,
onRemove,
}: {
label: string;
icon: ReactNode;
/** A place's picture fills the chip's round end, so the chip starts closer to it. */
round: boolean;
onRemove: () => void;
}) {
const { t } = useTranslation();
return (
<span
className={`inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-full bg-surface-secondary py-0.5 pe-1 text-caption text-content-secondary ${round ? 'ps-0.5' : 'ps-2'}`}
>
<span className="flex flex-shrink-0">{icon}</span>
<bdi className="truncate">{label}</bdi>
<button
type="button"
onClick={onRemove}
aria-label={t('assistant.context.remove')}
className="rounded-full p-0.5 text-content-faint hover:bg-surface-hover hover:text-content"
>
<X size={12} aria-hidden="true" />
</button>
</span>
);
}
function SendButton({ size }: { size: string }) {
const { t } = useTranslation();
// Sending waits while a proposed change is undecided; the tooltip says so.
const blocked = useAuiState((s) => s.thread.isSendDisabled);
const button = (
<ComposerPrimitive.Send
aria-label={t('assistant.composer.send')}
className={`flex ${size} flex-shrink-0 items-center justify-center rounded-full bg-accent text-accent-text shadow-card transition-[transform,opacity,box-shadow] duration-150 hover:scale-105 hover:bg-accent-hover hover:shadow-dropdown active:scale-95 disabled:scale-100 disabled:opacity-30 disabled:shadow-none`}
>
<ArrowUp size={17} strokeWidth={2.4} aria-hidden="true" />
</ComposerPrimitive.Send>
);
// A disabled button gets no pointer events, so the tooltip that says why hangs on a wrapper.
return blocked ? (
<Tooltip label={t('assistant.composer.decideFirst')} placement="top">
<span className="inline-flex">{button}</span>
</Tooltip>
) : (
button
);
}
@@ -1,137 +0,0 @@
import type { AssistantChangeEffect, AssistantMessage } from '@trek/shared';
import { ArrowRightLeft, Minus, Sparkles } from 'lucide-react';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../i18n';
import { changeEffects } from '../../store/assistantMapModel';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
/** What a waiting change does to one day, as a row of the preview. */
interface PreviewRow {
toolCallId: string;
kind: 'add' | 'in' | 'remove' | 'out';
name: string;
}
/** The changes no one has decided on yet, by their call. */
function waitingCalls(messages: readonly AssistantMessage[]): Set<string> {
const ids = new Set<string>();
for (const m of messages) {
for (const p of m.parts) {
if (p.type === 'tool-call' && p.approval && !p.approval.resolution && p.approval.approved === undefined) {
ids.add(p.toolCallId);
}
}
}
return ids;
}
function rowOf(
toolCallId: string,
effect: AssistantChangeEffect,
dayId: number,
nameOf: (id: number | null) => string | null
) {
const name = effect.place?.name ?? nameOf(effect.placeId);
if (!name) return null;
if (effect.dayId === dayId) {
const kind = effect.kind === 'remove' ? 'remove' : effect.kind === 'move' ? 'in' : 'add';
return { toolCallId, kind, name } satisfies PreviewRow;
}
if (effect.kind === 'move' && effect.fromDayId === dayId)
return { toolCallId, kind: 'out', name } satisfies PreviewRow;
return null;
}
const LOOK: Record<PreviewRow['kind'], { icon: typeof Sparkles; row: string; name: string; badgeKey: string }> = {
add: {
icon: Sparkles,
row: 'border-dashed border-accent bg-accent-subtle',
name: 'text-content',
badgeKey: 'assistant.preview.suggested',
},
in: {
icon: ArrowRightLeft,
row: 'border-dashed border-accent bg-accent-subtle',
name: 'text-content',
badgeKey: 'assistant.preview.movedHere',
},
remove: {
icon: Minus,
row: 'border-dashed border-edge',
name: 'text-content-faint line-through',
badgeKey: 'assistant.preview.removed',
},
out: {
icon: ArrowRightLeft,
row: 'border-dashed border-edge',
name: 'text-content-faint line-through',
badgeKey: 'assistant.preview.movedAway',
},
};
/**
* The assistant's waiting changes inside the day they touch, in the planner's
* own day list: a place it would add as a dashed row, one it would take off
* struck through, a move on both of its days. Resting on a row lights the
* change's pin and card; a click brings the chat forward to decide. Nothing
* here writes; the change card stays the one place a change is applied.
*/
export default function AssistantDayPreview({ tripId, dayId }: { tripId: number; dayId: number }) {
const { t } = useTranslation();
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
const hovered = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null));
const { setMapHover, open } = useAssistantStore(useShallow((s) => ({ setMapHover: s.setMapHover, open: s.open })));
const places = useTripStore((s) => s.places);
const rows = useMemo(() => {
const waiting = waitingCalls(messages);
if (waiting.size === 0) return [];
const nameOf = (id: number | null) => (id === null ? null : (places.find((p) => p.id === id)?.name ?? null));
const out: PreviewRow[] = [];
for (const [toolCallId, effect] of changeEffects(messages)) {
if (!waiting.has(toolCallId)) continue;
const row = rowOf(toolCallId, effect, dayId, nameOf);
if (row) out.push(row);
}
return out;
}, [messages, places, dayId]);
if (rows.length === 0) return null;
return (
<ul className="m-0 flex list-none flex-col gap-1 px-3 pb-2 pt-1" aria-label={t('assistant.preview.title')}>
{rows.map((row, i) => {
const look = LOOK[row.kind];
const Icon = look.icon;
const lit = hovered === row.toolCallId;
return (
<li
key={`${row.toolCallId}-${row.kind}`}
className="trek-assistant-enter"
style={{ animationDelay: `${i * 50}ms` }}
>
<button
type="button"
onClick={open}
onMouseEnter={() => setMapHover(tripId, row.toolCallId, 'card')}
onMouseLeave={() => setMapHover(tripId, null, 'card')}
className={`flex w-full items-center gap-2 rounded-xl border px-2.5 py-1.5 text-start transition-all ${look.row} ${
lit ? 'shadow-md ring-2 ring-accent' : 'hover:shadow-sm'
}`}
>
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-lg bg-surface-card text-content-muted shadow-sm">
<Icon size={12} aria-hidden="true" />
</span>
<bdi className={`min-w-0 flex-1 truncate text-caption font-medium ${look.name}`}>{row.name}</bdi>
<span className="flex-shrink-0 rounded-full bg-surface-card px-2 py-0.5 text-caption text-content-muted shadow-sm">
{t(look.badgeKey)}
</span>
</button>
</li>
);
})}
</ul>
);
}
@@ -1,135 +0,0 @@
/**
* FE-AI-ERR-001 to FE-AI-ERR-008
*
* The error line: one sentence, at most one thing to do, and that thing
* does what it says.
*/
import type { AiStatusResponse, AssistantErrorCode } from '@trek/shared';
import { Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { setForcedOffline } from '../../sync/networkMode';
import AssistantError from './AssistantError';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const STATUS: AiStatusResponse = {
available: true,
policy: 'instance_or_user',
canConnectOwn: true,
effective: { source: 'user', kind: 'anthropic', model: 'claude', host: 'api.anthropic.com', paidBy: 'user' },
own: null,
instance: null,
quota: null,
privacyAcked: true,
retention: { mode: 'days', days: 90 },
openrouterOAuth: true,
};
const retry = vi.fn(async () => {});
const continueHere = vi.fn(async () => {});
const send = vi.fn(async () => {});
function withError(error: AssistantErrorCode | null, status: AiStatusResponse = STATUS) {
useAssistantStore.setState({
status,
retry,
continueHere,
send,
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), error } },
});
}
function renderLine() {
return render(
<Routes>
<Route path="/trips/4" element={<AssistantError tripId={TRIP} />} />
<Route path="/settings" element={<p>settings page</p>} />
</Routes>,
{ initialEntries: ['/trips/4'] }
);
}
beforeEach(() => setForcedOffline(false));
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
setForcedOffline(false);
vi.clearAllMocks();
});
describe('AssistantError', () => {
it('FE-AI-ERR-001: shows nothing while all is well', () => {
withError(null);
const { container } = renderLine();
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-ERR-002: names the provider and retries a provider that was down', () => {
withError('provider_unavailable');
renderLine();
expect(screen.getByRole('status')).toHaveTextContent('Anthropic isn’t responding right now.');
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(retry).toHaveBeenCalledWith(TRIP);
});
it('FE-AI-ERR-003: a busy chat continues here, and a long run goes on with "Continue"', () => {
withError('busy');
const { unmount } = renderLine();
fireEvent.click(screen.getByRole('button', { name: 'Continue here' }));
expect(continueHere).toHaveBeenCalledWith(TRIP);
unmount();
withError('max_steps');
renderLine();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(send).toHaveBeenCalledWith(TRIP, 'Continue');
});
it('FE-AI-ERR-004: an expired sign-in leads straight to the settings card', () => {
withError('needs_reauth');
renderLine();
fireEvent.click(screen.getByRole('button', { name: 'Sign in again' }));
expect(screen.getByText('settings page')).toBeInTheDocument();
});
it('FE-AI-ERR-006: an own key that stopped working says so before anything is sent', () => {
withError(null, {
...STATUS,
own: {
scope: 'user',
kind: 'anthropic',
model: 'claude',
hint: '…a1b2',
status: 'error',
host: 'api.anthropic.com',
},
});
renderLine();
fireEvent.click(screen.getByRole('button', { name: 'Replace key' }));
expect(screen.getByText('settings page')).toBeInTheDocument();
});
it('FE-AI-ERR-007: a provider limit names the provider, without guessing at its plan', () => {
withError('rate_limited');
renderLine();
expect(screen.getByRole('status')).toHaveTextContent('Limit reached at Anthropic. Try again later.');
expect(screen.queryByRole('button')).toBeNull();
});
it('FE-AI-ERR-008: waiting changes ask for a decision before a place can be suggested', () => {
withError('decide_first');
renderLine();
expect(screen.getByRole('status')).toHaveTextContent('Decide on the open suggestions first.');
});
it('FE-AI-ERR-005: offline wins over any other error and offers nothing to press', () => {
withError('provider_unavailable');
setForcedOffline(true);
renderLine();
expect(screen.getByRole('status')).toHaveTextContent('Offline. The assistant needs internet.');
expect(screen.queryByRole('button')).toBeNull();
});
});
@@ -1,62 +0,0 @@
import { CircleAlert, WifiOff } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { type AssistantErrorActionKind, connectionErrorOf, errorView } from './assistantModel';
import { AI_SETTINGS_PATH } from './assistantParts';
import { CHATGPT_USAGE_URL, providerLabel } from './providerCatalog';
/**
* Why the assistant cannot go on, as one line above the composer with at
* most one thing to do about it: try again, continue, sign in again, and so
* on. Offline needs no button; the line says why the composer is off.
*/
export default function AssistantError({ tripId }: { tripId: number }) {
const { t } = useTranslation();
const navigate = useNavigate();
const { offline } = useNetworkMode();
const error = useAssistantStore((s) => chatOf(s, tripId).error);
const status = useAssistantStore((s) => s.status);
const retry = useAssistantStore((s) => s.retry);
const continueHere = useAssistantStore((s) => s.continueHere);
const send = useAssistantStore((s) => s.send);
const code = offline ? 'offline' : (error ?? connectionErrorOf(status));
if (!code) return null;
const effective = status?.effective;
const provider = effective ? providerLabel(effective.kind, effective.host) : '';
const view = errorView(code, { canConnectOwn: status?.canConnectOwn ?? false, provider });
const run = (kind: AssistantErrorActionKind) => {
if (kind === 'retry') void retry(tripId);
else if (kind === 'continueHere') void continueHere(tripId);
// The model asked whether to go on; the answer is the button's own word.
else if (kind === 'continue') void send(tripId, t('assistant.errorAction.continue'));
else if (kind === 'openSettings') void navigate(AI_SETTINGS_PATH);
else window.open(CHATGPT_USAGE_URL, '_blank', 'noopener,noreferrer');
};
const { action } = view;
const Icon = code === 'offline' ? WifiOff : CircleAlert;
return (
<div
role="status"
className="trek-assistant-enter flex items-center gap-2.5 rounded-lg bg-surface-secondary px-3 py-2 text-caption text-content-secondary"
>
<Icon size={14} aria-hidden="true" className="flex-shrink-0 text-warning" />
<span className="min-w-0 flex-1">{t(view.messageKey, { provider })}</span>
{action && (
<button
type="button"
onClick={() => run(action.kind)}
className="flex-shrink-0 rounded-md px-2 py-1 text-caption font-medium text-accent-on hover:bg-surface-hover"
>
{t(action.labelKey)}
</button>
)}
</div>
);
}
@@ -1,74 +0,0 @@
import { CircleCheck, TriangleAlert } from 'lucide-react';
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useTranslation } from '../../i18n';
import { useTripStore } from '../../store/tripStore';
import { Tooltip } from '../shared/Tooltip';
import { useAssistantView } from './assistantViewContext';
import { buildDayAfter, changedDays, type DayChange, type TimelineStop } from './dayAfterModel';
/** Lines the tooltip names; the rest are only counted on the pill. */
const MAX_LINES = 3;
/** What does not fit on a stop of the day: arriving after the next thing starts, or a way too tight. */
function problemOf(stop: TimelineStop, t: (key: string, params?: Record<string, string | number>) => string) {
const lead = stop.lead;
if (lead?.overlapsWith) return t('assistant.change.timeline.overlap', { place: lead.overlapsWith });
if (lead?.shortBy != null) return `${stop.name}: ${t('assistant.change.timeline.tight', { count: lead.shortBy })}`;
return null;
}
/**
* Whether the days a waiting card changes still work out in time, as one
* small pill in the card's head: "fits" in green, or how many things to look
* at in amber, with what they are on hover. The days themselves show the
* suggestion in the planner's day list, so the card does not draw them again.
* Shown only while the planner holds this trip, whose day plans it reads.
*/
export default function AssistantFitCheck({ changes }: { changes: readonly DayChange[] }) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const plan = useTripStore(
useShallow((s) => ({ here: s.trip?.id === tripId, days: s.days, assignments: s.assignments, places: s.places }))
);
const check = useMemo(() => {
if (!plan.here || changes.length === 0) return null;
const stops = changedDays(changes).flatMap((dayId) => {
const day = plan.days.find((d) => d.id === dayId);
if (!day) return [];
return buildDayAfter({
dayId,
assignments: plan.assignments,
places: plan.places,
changes,
defaultMode: day.default_transport_mode,
}).stops;
});
const measured = stops.some((stop) => stop.lead?.travelMinutes != null);
const problems = stops.map((stop) => problemOf(stop, t)).filter((line): line is string => line !== null);
return measured || problems.length > 0 ? problems : null;
}, [plan, changes, t]);
if (!check) return null;
const ok = check.length === 0;
const pill = (
<span
className={`inline-flex h-6 flex-shrink-0 items-center gap-1 rounded-full px-2 text-caption font-medium ${
ok ? 'bg-success-soft text-success' : 'bg-warning-soft text-warning'
}`}
>
{ok ? <CircleCheck size={12} aria-hidden="true" /> : <TriangleAlert size={12} aria-hidden="true" />}
{ok ? t('assistant.check.ok') : t('assistant.check.issues', { count: check.length })}
</span>
);
if (ok) return pill;
return (
<Tooltip label={check.slice(0, MAX_LINES).join('\n')} placement="top">
<span tabIndex={0} className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
{pill}
</span>
</Tooltip>
);
}
@@ -1,58 +0,0 @@
import type { AssistantMessage } from '@trek/shared';
import { CornerDownRight } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useCanDo } from '../../store/permissionsStore';
import { useTripStore } from '../../store/tripStore';
import { followUpsFor } from './assistantFollowUpRules';
import { hasUndecided } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
/** Each chip comes in a moment after the one before it. */
const STAGGER_MS = 60;
/**
* The next questions under the latest answer, one tap each. Only under the
* last answer, once it is written, and not while changes wait for a decision:
* then the cards are what to answer.
*/
export default function AssistantFollowUps({ message }: { message: AssistantMessage }) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const can = useCanDo();
const trip = useTripStore((s) => (s.trip?.id === tripId ? s.trip : null));
const send = useAssistantStore((s) => s.send);
const shown = useAssistantStore((s) => {
const chat = chatOf(s, tripId);
const last = chat.messages[chat.messages.length - 1];
return !chat.isRunning && !chat.error && last?.id === message.id && !hasUndecided(chat.messages);
});
if (!shown) return null;
const chips = followUpsFor(message, !!trip && can('place_edit', trip));
if (chips.length === 0) return null;
return (
<div role="group" aria-label={t('assistant.followUp.title')} className="flex flex-wrap gap-1.5 pt-0.5">
{chips.map((chip, i) => {
const text = t(chip.labelKey);
return (
<button
key={chip.id}
type="button"
onClick={() => void send(tripId, text)}
className="trek-assistant-enter group/chip inline-flex max-w-full items-center gap-1.5 rounded-full bg-surface-card px-3 py-1.5 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md hover:ring-edge active:translate-y-0"
style={{ animationDelay: `${i * STAGGER_MS}ms` }}
>
<CornerDownRight
size={12}
aria-hidden="true"
className="flex-shrink-0 text-content-faint transition-colors group-hover/chip:text-content rtl:-scale-x-100"
/>
<span className="truncate">{text}</span>
</button>
);
})}
</div>
);
}
@@ -1,219 +0,0 @@
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../i18n';
import { PopoverItem } from '../Packing/PackingPopover';
import ConfirmDialog from '../shared/ConfirmDialog';
import { ContextMenu, useContextMenu } from '../shared/ContextMenu';
import { NEUTRAL_TINT } from '../shared/DialogShell';
import Tooltip from '../shared/Tooltip';
import { PopoverListPanel } from '../shared/dialogParts';
import { usePopoverList } from '../shared/usePopoverList';
import AssistantMemoryDialog from './AssistantMemoryDialog';
import { AI_SETTINGS_PATH } from './assistantParts';
import { useAssistantHintsSetting } from './useAssistantHints';
import { useAssistantLiveStep } from './useAssistantLiveStep';
import type { useAssistantThread } from './useAssistantThread';
import { useTripMemory } from './useTripMemory';
// The round, raised buttons of TREK's dialog heads.
const ICON_BUTTON =
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content';
interface AssistantHeaderProps {
tripId: number;
thread: ReturnType<typeof useAssistantThread>;
tripTitle: string;
onClose: () => void;
/** Moves the window: spread on the title, which is the part of the header to grab. */
dragProps: HTMLAttributes<HTMLElement>;
}
/**
* The window's head, in the look of TREK's dialog heads: a tinted band with
* a small line naming the assistant over the conversation's name; the band is where the window is
* grabbed to move it. On the end side the recent chats (a new chat starts at
* the top of that list), more and close, as round raised buttons. Closing
* keeps the conversation, and an answer being written goes on. The model is not picked here;
* that lives in the settings.
*/
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
const { t, locale } = useTranslation();
const live = useAssistantLiveStep(tripId);
const memory = useTripMemory(tripId);
const [memoryOpen, setMemoryOpen] = useState(false);
const navigate = useNavigate();
const menu = useContextMenu();
const [confirming, setConfirming] = useState(false);
const threads = thread.threads ?? [];
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
const { open: historyOpen, triggerRef: historyTrigger, listId: historyListId } = history;
const hints = useAssistantHintsSetting();
const openHistory = () => {
if (!history.open) thread.refreshThreads();
history.toggle();
};
const pick = (run: () => void) => {
history.close();
run();
};
const openMenu = (e: MouseEvent) =>
menu.open(
e,
[
{
label: t('assistant.history.new'),
icon: Plus,
disabled: thread.threadId === null && !thread.isRunning,
onClick: thread.newThread,
},
{
label: t(hints.on ? 'assistant.hint.menuOff' : 'assistant.hint.menuOn'),
icon: hints.on ? LightbulbOff : Lightbulb,
onClick: hints.toggle,
},
{ label: t('assistant.window.settings'), icon: Settings, onClick: () => void navigate(AI_SETTINGS_PATH) },
{
label: t('assistant.window.deleteThread'),
icon: Trash2,
danger: true,
disabled: thread.threadId === null || thread.isRunning,
onClick: () => setConfirming(true),
},
],
true
);
return (
<header
className="flex flex-shrink-0 items-center gap-2 border-b border-edge-faint py-2.5 pe-2.5 ps-4"
style={{ background: NEUTRAL_TINT }}
>
<div
{...dragProps}
className="flex min-w-0 flex-1 cursor-grab touch-none select-none items-center active:cursor-grabbing"
>
<div className="min-w-0 flex-1">
{thread.isRunning ? (
// While it works, the line over the title says what it does right now.
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption font-medium">
<span
aria-hidden="true"
className="trek-assistant-pulse h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
/>
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
</p>
) : (
<p className="m-0 truncate font-geist text-caption font-bold uppercase tracking-[.08em] text-content-muted">
{t('assistant.launcher.tooltip')}
</p>
)}
<h2 className="m-0 truncate text-body font-bold tracking-[-0.01em] text-content">
<bdi>{thread.title ?? tripTitle}</bdi>
</h2>
</div>
</div>
{/* Buttons are no place to start a drag from. */}
<div
className="flex flex-shrink-0 items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
<button
type="button"
onClick={() => setMemoryOpen(true)}
aria-label={t('assistant.memory.title')}
className={`relative ${ICON_BUTTON}`}
>
<NotebookPen size={13} aria-hidden="true" />
{memory.has && (
<span
aria-hidden="true"
className="absolute -end-0.5 -top-0.5 h-2 w-2 rounded-full bg-accent ring-2 ring-[color:var(--bg-elevated)]"
/>
)}
</button>
</Tooltip>
<Tooltip label={t('assistant.history.open')}>
<button
ref={historyTrigger}
type="button"
onClick={openHistory}
aria-label={t('assistant.history.open')}
aria-expanded={historyOpen}
aria-controls={historyOpen ? historyListId : undefined}
className={ICON_BUTTON}
>
<History size={13} aria-hidden="true" />
</button>
</Tooltip>
<Tooltip label={t('assistant.window.menu')}>
<button
type="button"
onClick={openMenu}
aria-label={t('assistant.window.menu')}
aria-haspopup="menu"
className={ICON_BUTTON}
>
<Ellipsis size={14} aria-hidden="true" />
</button>
</Tooltip>
<Tooltip label={t('common.close')}>
<button type="button" onClick={onClose} aria-label={t('common.close')} className={ICON_BUTTON}>
<X size={14} aria-hidden="true" />
</button>
</Tooltip>
</div>
<PopoverListPanel list={history} label={t('assistant.history.open')} className="w-72">
<PopoverItem
icon={<Plus size={14} />}
label={t('assistant.history.new')}
onClick={() => pick(thread.newThread)}
/>
{threads.length === 0 ? (
<p className="m-0 px-2.5 py-2 text-caption text-content-muted">{t('assistant.history.empty')}</p>
) : (
threads.map((th) => (
<PopoverItem
key={th.id}
icon={null}
label={th.title}
active={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))}
trailing={
<span className="flex-shrink-0 text-caption text-content-faint">
{new Date(th.lastMessageAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' })}
</span>
}
/>
))
)}
{thread.retentionNote && (
<p className="m-0 border-t border-edge-faint px-2.5 pb-1 pt-2 text-caption text-content-faint">
{thread.retentionNote}
</p>
)}
</PopoverListPanel>
<ContextMenu menu={menu.menu} onClose={menu.close} />
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
<ConfirmDialog
isOpen={confirming}
onClose={() => setConfirming(false)}
onConfirm={() => {
setConfirming(false);
void thread.removeThread();
}}
title={t('assistant.window.deleteThread')}
message={t('assistant.window.deleteConfirm')}
confirmLabel={t('common.delete')}
/>
</header>
);
}
@@ -1,88 +0,0 @@
/**
* FE-AI-HINTLIST-001 to FE-AI-HINTLIST-006
*
* The hint list in the empty chat: three rows at most with the rest behind
* "n more", a row that asks, an X that waves one away, rows shown counted as
* seen, and rows that wait while offline.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import AssistantHints from './AssistantHints';
import type { AssistantHintsView, ReadyHint } from './useAssistantHints';
const network = vi.hoisted(() => ({ offline: false }));
vi.mock('../../hooks/useNetworkMode', () => ({
useNetworkMode: () => ({ offline: network.offline, forced: false, setForced: () => undefined }),
}));
function hint(id: string, level: ReadyHint['level'] = 'info'): ReadyHint {
const rule = level === 'warn' ? 'overlap' : 'gap';
return { id, rule, level, text: `Line ${id}`, prompt: `Ask ${id}`, focus: null };
}
function view(hints: ReadyHint[]): AssistantHintsView {
return {
hints,
unseenWarn: 0,
markSeen: vi.fn(),
markWarnsSeen: vi.fn(),
dismiss: vi.fn(),
ask: vi.fn(),
};
}
beforeEach(() => {
network.offline = false;
});
describe('AssistantHints', () => {
it('FE-AI-HINTLIST-001: shows nothing without hints, and three rows with the rest behind "n more"', () => {
const { container, unmount } = render(<AssistantHints view={view([])} phone={false} />);
expect(container).toBeEmptyDOMElement();
unmount();
const five = view(['a', 'b', 'c', 'd', 'e'].map((id) => hint(id)));
render(<AssistantHints view={five} phone={false} />);
expect(screen.getByRole('region', { name: 'Worth a look' })).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(3);
expect(five.markSeen).toHaveBeenLastCalledWith(['a', 'b', 'c']);
fireEvent.click(screen.getByRole('button', { name: '2 more' }));
expect(screen.getAllByRole('listitem')).toHaveLength(5);
expect(five.markSeen).toHaveBeenLastCalledWith(['a', 'b', 'c', 'd', 'e']);
});
it('FE-AI-HINTLIST-002: a row asks its question, and the X waves the hint away', () => {
const one = view([hint('a', 'warn')]);
render(<AssistantHints view={one} phone={false} />);
fireEvent.click(screen.getByRole('button', { name: 'Line a' }));
expect(one.ask).toHaveBeenCalledWith(one.hints[0]);
fireEvent.click(screen.getByRole('button', { name: 'Hide this hint' }));
expect(one.dismiss).toHaveBeenCalledWith('a');
});
it('FE-AI-HINTLIST-003: on the phone the X is always there, with room for a thumb', () => {
render(<AssistantHints view={view([hint('a')])} phone />);
const dismiss = screen.getByRole('button', { name: 'Hide this hint' });
expect(dismiss.className).toContain('h-10');
expect(dismiss.className).not.toContain('opacity-0');
});
it('FE-AI-HINTLIST-006: in the window the X hides until hover only on a screen that can hover', () => {
render(<AssistantHints view={view([hint('a')])} phone={false} />);
const dismiss = screen.getByRole('button', { name: 'Hide this hint' });
expect(dismiss.className).toContain('[@media(hover:hover)]:opacity-0');
expect(dismiss.className.split(' ')).not.toContain('opacity-0');
});
it('FE-AI-HINTLIST-004: offline the rows stay readable but do not ask', () => {
network.offline = true;
const one = view([hint('a')]);
render(<AssistantHints view={one} phone={false} />);
const row = screen.getByRole('button', { name: 'Line a' });
expect(row).toBeDisabled();
fireEvent.click(row);
expect(one.ask).not.toHaveBeenCalled();
});
});
@@ -1,114 +0,0 @@
import { BedDouble, Clock, type LucideIcon, MapPin, Ticket, TriangleAlert, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { useTranslation } from '../../i18n';
import Tooltip from '../shared/Tooltip';
import type { HintRule } from './assistantHintRules';
import type { AssistantHintsView, ReadyHint } from './useAssistantHints';
/** Rows shown before "n more", so the suggestions under them stay in view. */
export const MAX_VISIBLE_HINTS = 3;
/** Warnings all carry the one triangle; a note says what it is about. */
const NOTE_ICONS: Partial<Record<HintRule, LucideIcon>> = {
gap: Clock,
longLeg: MapPin,
noStay: BedDouble,
unplacedBooking: Ticket,
};
/**
* The plan hints in the empty chat: a short, quiet list, at most three rows
* with the rest behind "n more". A row is the button: it puts its day or
* places in focus and asks its question. Each can be waved away. On the
* desktop the X shows on hover or focus, on the phone always, with room for
* a thumb. Shown means seen, so the launcher stops pointing at them.
*/
export default function AssistantHints({ view, phone }: { view: AssistantHintsView; phone: boolean }) {
const { t } = useTranslation();
const { offline } = useNetworkMode();
const [expanded, setExpanded] = useState(false);
const shown = expanded ? view.hints : view.hints.slice(0, MAX_VISIBLE_HINTS);
const rest = view.hints.length - shown.length;
const shownIds = shown.map((h) => h.id).join('\n');
const { markSeen } = view;
useEffect(() => {
if (shownIds) markSeen(shownIds.split('\n'));
}, [shownIds, markSeen]);
if (view.hints.length === 0) return null;
return (
<section aria-label={t('assistant.hint.title')} className="flex w-full max-w-sm flex-col gap-1.5">
<h3 className="m-0 px-1 text-caption font-medium text-content-muted">{t('assistant.hint.title')}</h3>
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
{shown.map((hint) => (
<HintRow key={hint.id} hint={hint} phone={phone} disabled={offline} view={view} />
))}
</ul>
{rest > 0 && (
<button
type="button"
onClick={() => setExpanded(true)}
className={`self-start rounded-lg px-1 text-caption text-content-muted hover:text-content ${phone ? 'min-h-[44px]' : 'py-1'}`}
>
{t('assistant.hint.more', { count: rest })}
</button>
)}
</section>
);
}
function HintRow({
hint,
phone,
disabled,
view,
}: {
hint: ReadyHint;
phone: boolean;
disabled: boolean;
view: AssistantHintsView;
}) {
const { t } = useTranslation();
const warn = hint.level === 'warn';
const Icon = warn ? TriangleAlert : (NOTE_ICONS[hint.rule] ?? Clock);
const dismissLabel = t('assistant.hint.dismiss');
return (
<li className="group relative">
<button
type="button"
disabled={disabled}
onClick={() => view.ask(hint)}
className={`flex w-full items-start gap-2 rounded-xl border border-edge-faint bg-surface-card py-2 ps-3 text-start text-body text-content transition-colors hover:border-edge hover:bg-surface-hover disabled:cursor-default disabled:opacity-60 disabled:hover:border-edge-faint disabled:hover:bg-surface-card ${
phone ? 'min-h-[44px] pe-11' : 'pe-9'
}`}
>
<Icon
size={14}
aria-hidden="true"
className={`mt-[3px] flex-shrink-0 ${warn ? 'text-content-secondary' : 'text-content-muted'}`}
/>
<span dir="auto" className="line-clamp-2 min-w-0 flex-1">
{hint.text}
</span>
</button>
{/* Hidden until hover only where there is hover: a touch screen at window size shows it always. */}
<Tooltip label={dismissLabel}>
<button
type="button"
onClick={() => view.dismiss(hint.id)}
aria-label={dismissLabel}
className={`absolute grid place-items-center rounded-lg text-content-faint transition-opacity hover:bg-surface-hover hover:text-content ${
phone
? 'end-0.5 top-0.5 h-10 w-10'
: 'end-1 top-1 h-7 w-7 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:hover)]:opacity-0'
}`}
>
<X size={13} aria-hidden="true" />
</button>
</Tooltip>
</li>
);
}
@@ -1,93 +0,0 @@
/**
* FE-AI-HOST-001 to FE-AI-HOST-005
*
* The desktop host follows the trip on screen and sends "Show on map" and a
* picked day to the planner through its links.
*/
import type { AssistantApplied } from '@trek/shared';
import { useLocation } from 'react-router';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen } from '../../../tests/helpers/render';
import { useTripStore } from '../../store/tripStore';
import type { Trip } from '../../types';
import AssistantHost from './AssistantHost';
interface WindowProps {
trip: { id: number; title: string } | null;
onShowOnMap?: (applied: AssistantApplied) => void;
onFocusDay?: (dayId: number) => void;
}
const seen: { props: WindowProps | null } = { props: null };
vi.mock('./AssistantWindow', () => ({
default: (props: WindowProps) => {
seen.props = props;
return null;
},
}));
function Location() {
const { pathname, search } = useLocation();
return <output data-testid="location">{`${pathname}${search}`}</output>;
}
function renderAt(path: string) {
return render(
<>
<AssistantHost />
<Location />
</>,
{ initialEntries: [path] }
);
}
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
toolCallId: 'c1',
entity,
ids,
dayId,
undoUntil: null,
});
const INITIAL = useTripStore.getState();
afterEach(() => {
useTripStore.setState(INITIAL, true);
seen.props = null;
});
describe('AssistantHost', () => {
it('FE-AI-HOST-001: outside a trip the window gets no trip and asks which one', () => {
renderAt('/dashboard');
expect(seen.props?.trip).toBeNull();
});
it('FE-AI-HOST-002: inside a trip it is about that trip, named once the planner has it', () => {
renderAt('/trips/5/files');
expect(seen.props?.trip).toEqual({ id: 5, title: '' });
act(() => useTripStore.setState({ trip: { id: 5, title: 'Kyoto' } as Trip }));
expect(seen.props?.trip).toEqual({ id: 5, title: 'Kyoto' });
});
it('FE-AI-HOST-003: "Show on map" opens the planner on the place, or on the day', () => {
renderAt('/trips/5/files');
act(() => seen.props?.onShowOnMap?.(applied('place', [7], 2)));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?place=7');
act(() => seen.props?.onShowOnMap?.(applied('day_note', [9], 3)));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=3');
});
it('FE-AI-HOST-005: a day picked in the chat selects it in the planner', () => {
renderAt('/trips/5');
act(() => seen.props?.onFocusDay?.(12));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=12');
});
it('FE-AI-HOST-004: a route that only looks like a trip is none', () => {
renderAt('/trips/new');
expect(seen.props?.trip).toBeNull();
});
});
@@ -1,43 +0,0 @@
import type { AssistantApplied } from '@trek/shared';
import { useCallback, useMemo } from 'react';
import { useLocation, useMatch, useNavigate } from 'react-router';
import { useTripStore } from '../../store/tripStore';
import AssistantWindow from './AssistantWindow';
import { showOnMapSearch } from './assistantShowOnMap';
/** The trip a route under /trips/:id shows, or null anywhere else. */
function useRouteTripId(): number | null {
const match = useMatch('/trips/:id/*');
const id = Number(match?.params.id);
return Number.isInteger(id) && id > 0 ? id : null;
}
/**
* The desktop assistant, mounted once above the routes so a conversation
* keeps going from page to page. Inside a trip it is about that trip; "Show
* on map" opens the planner on the change through its links (`?place=`,
* `?day=`), the same way a notification opens a place, and a day picked in
* the chat (its weather) selects that day the same way.
*/
export default function AssistantHost() {
const navigate = useNavigate();
const { pathname } = useLocation();
const tripId = useRouteTripId();
// The title is there once the planner has loaded the trip; the window waits for nothing else.
const title = useTripStore((s) => (s.trip && s.trip.id === tripId ? s.trip.title : ''));
const trip = useMemo(() => (tripId === null ? null : { id: tripId, title }), [tripId, title]);
const openPlanner = useCallback(
(search: string) => {
if (tripId === null) return;
const planner = `/trips/${tripId}`;
void navigate({ pathname: planner, search }, { replace: pathname === planner });
},
[navigate, pathname, tripId]
);
const showOnMap = useCallback((applied: AssistantApplied) => openPlanner(showOnMapSearch(applied)), [openPlanner]);
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
return <AssistantWindow trip={trip} onShowOnMap={showOnMap} onFocusDay={focusDay} />;
}
@@ -1,128 +0,0 @@
/**
* FE-AI-LAUNCH-001 to FE-AI-LAUNCH-006
*
* The way in: hidden until the assistant can run, a button that opens and
* closes the window, a dot for warnings in the plan not seen yet, and
* ⌘K / Ctrl+K, which leaves a dialog alone.
*/
import { renderHook } from '@testing-library/react';
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import AssistantLauncher from './AssistantLauncher';
import { isAssistantHotkey, useAssistantHotkey } from './useAssistantHotkey';
const INITIAL = useAssistantStore.getState();
const STATUS = { available: true } as AiStatusResponse;
function press(init: KeyboardEventInit): KeyboardEvent {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
window.dispatchEvent(event);
return event;
}
afterEach(() => useAssistantStore.setState(INITIAL, true));
describe('AssistantLauncher', () => {
it('FE-AI-LAUNCH-001: shows nothing until the assistant has a connection to use', () => {
useAssistantStore.setState({ status: { ...STATUS, available: false } });
const { container } = render(<AssistantLauncher variant="navbar" />);
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-LAUNCH-002: the navbar button and the floating one open and close the window', () => {
useAssistantStore.setState({ status: STATUS });
const { unmount } = render(<AssistantLauncher variant="navbar" />);
const button = screen.getByRole('button', { name: 'Assistant' });
fireEvent.click(button);
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
expect(button).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(button);
expect(useAssistantStore.getState().window.open).toBe(false);
unmount();
render(<AssistantLauncher variant="floating" />);
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
expect(useAssistantStore.getState().window.open).toBe(true);
});
it('FE-AI-LAUNCH-006: the floating button carries a dot for a warning not seen yet, until the chat is opened', () => {
window.localStorage.clear();
resetAllStores();
const places = [
buildPlace({ id: 50, place_time: '10:00', end_time: '12:00' }),
buildPlace({ id: 51, place_time: '11:00', end_time: '12:30' }),
];
const stops = places.map((place, i) =>
buildAssignment({ id: 60 + i, day_id: 1, place_id: place.id, order_index: i, place })
);
useTripStore.setState({
trip: buildTrip({ id: 3, start_date: '2099-06-01', end_date: '2099-06-01' }),
days: [buildDay({ id: 1, trip_id: 3, date: '2099-06-01' })],
places,
assignments: { '1': stops },
reservations: [],
dayNotes: {},
});
useAssistantStore.setState({ status: STATUS });
const { unmount } = render(<AssistantLauncher variant="navbar" />);
expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument();
unmount();
render(<AssistantLauncher variant="floating" tripId={3} />);
const button = screen.getByRole('button', { name: 'Assistant, 1 hint' });
// The dot stands out from the accent button it sits on.
expect(button.querySelector('.bg-warning')).not.toBeNull();
fireEvent.click(button);
expect(useAssistantStore.getState().window.open).toBe(true);
expect(screen.getByRole('button', { name: 'Assistant' }).querySelector('.bg-warning')).toBeNull();
});
});
describe('the shortcut', () => {
it('FE-AI-LAUNCH-003: is ⌘K or Ctrl+K, without Alt or Shift', () => {
const key = (init: KeyboardEventInit) => isAssistantHotkey(new KeyboardEvent('keydown', init));
expect(key({ key: 'k', metaKey: true })).toBe(true);
expect(key({ key: 'K', ctrlKey: true })).toBe(true);
expect(key({ key: 'k' })).toBe(false);
expect(key({ key: 'k', ctrlKey: true, altKey: true })).toBe(false);
expect(key({ key: 'k', metaKey: true, shiftKey: true })).toBe(false);
expect(key({ key: 'j', metaKey: true })).toBe(false);
});
it('FE-AI-LAUNCH-004: toggles and takes the key, only while enabled', () => {
const onToggle = vi.fn();
const { rerender } = renderHook(({ enabled }) => useAssistantHotkey(enabled, onToggle), {
initialProps: { enabled: false },
});
expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(false);
expect(onToggle).not.toHaveBeenCalled();
rerender({ enabled: true });
expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(true);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it('FE-AI-LAUNCH-005: leaves the key alone while a dialog is open or something else took it', () => {
const onToggle = vi.fn();
renderHook(() => useAssistantHotkey(true, onToggle));
const backdrop = document.createElement('div');
backdrop.className = 'trek-modal-backdrop';
document.body.appendChild(backdrop);
press({ key: 'k', metaKey: true });
backdrop.remove();
const taken = new KeyboardEvent('keydown', { key: 'k', metaKey: true, cancelable: true });
taken.preventDefault();
window.dispatchEvent(taken);
expect(onToggle).not.toHaveBeenCalled();
});
});
@@ -1,106 +0,0 @@
import { Sparkles } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { useAssistantStore } from '../../store/assistantStore';
import Tooltip from '../shared/Tooltip';
import { useAssistantHints } from './useAssistantHints';
import { useAssistantShortcutKeys } from './useAssistantHotkey';
/** Where the floating button sits, from the window's end and bottom edges. The window grows out of it. */
export const LAUNCHER_INSET = 24;
export const LAUNCHER_SIZE = 48;
/**
* The way into the assistant: a button in the navbar, and in the planner a
* round one floating at the bottom end of the screen. Both open and close the
* window; outside a trip it opens on the trip picker. Nothing shows until the
* assistant has a connection this user can use: no setup prompt in its place.
* The floating one carries a dot while the trip has a warning the user has
* not been shown; opening the chat counts them as seen.
*/
export default function AssistantLauncher({
variant,
tripId = null,
focusOnMount = false,
}: {
variant: 'navbar' | 'floating';
/** The trip the floating button stands in, for its hint dot. */
tripId?: number | null;
/** Takes the focus when it appears, as when the window it stands for was just closed. */
focusOnMount?: boolean;
}) {
const { t } = useTranslation();
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open && !s.window.minimized);
const toggle = useAssistantStore((s) => s.toggle);
// An answer still being written in a closed window: the floating button breathes until it is opened.
const busy = useAssistantStore((s) =>
!s.window.open || s.window.minimized ? Object.values(s.byTrip).some((c) => c?.isRunning) : false
);
const keys = useAssistantShortcutKeys();
const hints = useAssistantHints(variant === 'floating' ? tripId : null, { withStays: false });
const floating = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (focusOnMount) floating.current?.focus();
}, [focusOnMount]);
if (!available) return null;
const label = t('assistant.launcher.tooltip');
if (variant === 'navbar') {
return (
<Tooltip label={label} shortcut={keys}>
<button
type="button"
onClick={toggle}
aria-label={label}
aria-pressed={open}
className={`relative hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg p-2 transition-colors sm:flex ${
open ? 'bg-surface-selected text-content' : 'text-content-muted hover:bg-surface-hover'
}`}
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
</button>
</Tooltip>
);
}
const pointed = hints.unseenWarn > 0 ? t('assistant.launcher.withHints', { count: hints.unseenWarn }) : label;
const openChat = () => {
if (!open) hints.markWarnsSeen();
toggle();
};
return (
<Tooltip label={pointed} shortcut={keys} placement="top">
<button
ref={floating}
type="button"
onClick={openChat}
aria-label={pointed}
aria-busy={busy || undefined}
className="trek-assistant-pop fixed z-[var(--z-nav)] flex items-center justify-center rounded-full bg-accent text-accent-text shadow-elevated transition-transform hover:scale-105 active:scale-95"
style={{
insetInlineEnd: LAUNCHER_INSET,
bottom: LAUNCHER_INSET,
width: LAUNCHER_SIZE,
height: LAUNCHER_SIZE,
transformOrigin: 'center',
}}
>
{busy && (
<span
aria-hidden="true"
className="trek-assistant-busy absolute inset-0 rounded-full border-2 border-current opacity-40"
/>
)}
<Sparkles size={20} aria-hidden="true" />
{hints.unseenWarn > 0 && (
<span
aria-hidden="true"
className="absolute end-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-warning ring-2 ring-[var(--bg-elevated)]"
/>
)}
</button>
</Tooltip>
);
}
@@ -1,81 +0,0 @@
/**
* FE-AI-LIVE-001 to FE-AI-LIVE-004
*
* The running step's line: what it does, and once the stream says where it
* looks, the place or the query, with a pin when the map can show the area.
*/
import type { AssistantActivity, AssistantMessage } from '@trek/shared';
import { afterEach, describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import AssistantToolSteps from './AssistantToolSteps';
import { AssistantViewContext } from './assistantViewContext';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const running: AssistantMessage = {
id: 'msg-2',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-21T08:00:00.000Z',
parts: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'search_nearby_places', args: {} }],
};
function seed(activity: AssistantActivity | null) {
useAssistantStore.setState({
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages: [running], live: { activity } } },
});
}
function renderSteps() {
return render(
<AssistantViewContext.Provider value={{ tripId: TRIP, variant: 'window' }}>
<AssistantToolSteps steps={[{ toolCallId: 'call-1', toolName: 'search_nearby_places', running: true }]} />
</AssistantViewContext.Provider>
);
}
afterEach(() => useAssistantStore.setState(INITIAL, true));
describe('AssistantLiveStep', () => {
it('FE-AI-LIVE-001: names the place the running step searches near', () => {
seed({
toolCallId: 'call-1',
label: 'Trastevere',
area: { type: 'point', lat: 41.88, lng: 12.47, radiusM: 800 },
});
const { container } = renderSteps();
expect(screen.getByRole('status')).toHaveTextContent('Searching near Trastevere');
expect(container.querySelector('svg')).not.toBeNull();
});
it('FE-AI-LIVE-002: without a place it keeps the step name, and no pin', () => {
seed({ toolCallId: 'call-1', label: null, area: null });
const { container } = renderSteps();
expect(screen.getByRole('status')).toHaveTextContent('Searching nearby');
expect(container.querySelector('svg')).toBeNull();
});
it("FE-AI-LIVE-003: a place another step named is not this step's", () => {
seed({ toolCallId: 'call-0', label: 'Trastevere', area: null });
renderSteps();
expect(screen.getByRole('status')).toHaveTextContent('Searching nearby');
});
it('FE-AI-LIVE-004: with "map follows the assistant" off the place stays, the pin goes', () => {
const settings = useSettingsStore.getState().settings;
useSettingsStore.setState({ settings: { ...settings, assistant_map_follow: false } });
seed({
toolCallId: 'call-1',
label: 'Trastevere',
area: { type: 'point', lat: 41.88, lng: 12.47, radiusM: 800 },
});
const { container } = renderSteps();
expect(screen.getByRole('status')).toHaveTextContent('Searching near Trastevere');
expect(container.querySelector('svg')).toBeNull();
useSettingsStore.setState({ settings });
});
});
@@ -1,24 +0,0 @@
import { MapPin } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { toolStepKey } from './assistantModel';
import { useAssistantView } from './assistantViewContext';
import { useAssistantLiveStep } from './useAssistantLiveStep';
/**
* The running step as one quiet line: what it does, shimmering, and once it
* names a place, where ("Searching near Trastevere"). A small pin says the
* map is looking there too, shown only while the map follows the assistant.
*/
export default function AssistantLiveStep({ toolCallId, toolName }: { toolCallId: string; toolName: string }) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const live = useAssistantLiveStep(tripId);
const here = live?.toolCallId === toolCallId ? live : null;
return (
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption">
{here?.hasArea && <MapPin size={12} aria-hidden="true" className="flex-shrink-0 text-content-faint" />}
<span className="trek-assistant-shimmer min-w-0 truncate">{here ? here.text : t(toolStepKey(toolName))}</span>
</p>
);
}
@@ -1,48 +0,0 @@
/**
* FE-AI-MD-001 to FE-AI-MD-005
*
* An answer is untrusted text: nothing in it may load by itself, raw HTML
* never becomes markup, and a link always shows where it goes.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import AssistantMarkdown from './AssistantMarkdown';
describe('AssistantMarkdown', () => {
it('FE-AI-MD-001: renders Markdown as text, lists and emphasis', () => {
const { container } = render(<AssistantMarkdown text={'**Kyoto** first\n\n- Kinkaku-ji\n- Ryoan-ji'} />);
expect(container.querySelector('strong')).toHaveTextContent('Kyoto');
expect(screen.getAllByRole('listitem')).toHaveLength(2);
});
it('FE-AI-MD-002: draws no image, in Markdown or as raw HTML', () => {
const { container } = render(
<AssistantMarkdown text={'![pixel](https://evil.example/p.gif)\n\n<img src="https://evil.example/q.gif">'} />
);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('picture, video, audio, iframe')).toBeNull();
});
it('FE-AI-MD-003: leaves no javascript: link to click', () => {
const { container } = render(<AssistantMarkdown text={'[open](javascript:alert(1))'} />);
const link = container.querySelector('a');
expect(link?.getAttribute('href') ?? '').not.toMatch(/^javascript:/i);
});
it('FE-AI-MD-004: an outside link opens in its own tab and names its host', () => {
render(<AssistantMarkdown text={'[Tickets](https://www.kinkaku-ji.or.jp/en/)'} />);
const link = screen.getByRole('link', { name: /Tickets/ });
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
expect(link).toHaveTextContent('(www.kinkaku-ji.or.jp)');
});
it('FE-AI-MD-005: a link into TREK stays in the app, and the writing caret is marked', () => {
const { container } = render(<AssistantMarkdown text={'See [day 2](/trips/4)'} writing />);
const link = screen.getByRole('link', { name: 'day 2' });
expect(link).toHaveAttribute('href', '/trips/4');
expect(link).not.toHaveAttribute('target');
expect(container.firstElementChild).toHaveAttribute('data-writing', 'true');
});
});
@@ -1,64 +0,0 @@
import { memo } from 'react';
import Markdown, { type Components } from 'react-markdown';
import { Link } from 'react-router';
import remarkGfm from 'remark-gfm';
import { sanitizedMarkdownPlugins } from '../shared/markdownSanitize';
/**
* An answer's text, as Markdown. The model writes it, and what the model reads
* (trip notes, place descriptions, search results) can carry someone else's
* words, so it renders like untrusted input: raw HTML is skipped and
* sanitized, and nothing that loads on its own (an image is a tracking pixel
* that leaks a request to any host) is drawn at all.
*
* A link shows where it goes: an outside one names its host beside the text
* and opens in its own tab, a link into TREK stays in the app.
*/
const DISALLOWED = ['img', 'picture', 'source', 'video', 'audio', 'iframe', 'object', 'embed'];
function hostOf(href: string): string | null {
try {
return new URL(href).host || null;
} catch {
return null;
}
}
const components: Components = {
a: ({ href, children }) => {
if (href?.startsWith('/') && !href.startsWith('//')) return <Link to={href}>{children}</Link>;
const host = href ? hostOf(href) : null;
const text = typeof children === 'string' ? children : null;
return (
<a href={href} target="_blank" rel="noopener noreferrer nofollow">
{children}
{host && text !== host && text !== href && <span className="text-content-muted"> ({host})</span>}
</a>
);
},
};
function AssistantMarkdown({ text, writing = false }: { text: string; writing?: boolean }) {
return (
<div
dir="auto"
data-writing={writing || undefined}
className="trek-assistant-md break-words text-body text-content"
>
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={sanitizedMarkdownPlugins}
disallowedElements={DISALLOWED}
skipHtml
components={components}
>
{text}
</Markdown>
</div>
);
}
// An answer re-renders with every streamed word; a block whose text did not change keeps its parse.
export default memo(AssistantMarkdown);
@@ -1,112 +0,0 @@
import { NotebookPen, Plus } from 'lucide-react';
import { useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { TEXTAREA } from '../shared/dialogParts';
import { MAX_MEMORY_CHARS, useTripMemory } from './useTripMemory';
/** What travellers most often want minded, one tap to add each. */
const QUICK = ['vegetarian', 'kids', 'budget', 'slow', 'accessible', 'noMuseums'] as const;
/**
* The trip's notes for the assistant: a few lines it follows in every
* suggestion on this trip, written once instead of in every question. The
* common ones are a tap away; the rest is free text.
*/
export default function AssistantMemoryDialog({ tripId, onClose }: { tripId: number; onClose: () => void }) {
const { t } = useTranslation();
const labelId = useId();
const memory = useTripMemory(tripId);
const [draft, setDraft] = useState(memory.text);
const [saving, setSaving] = useState(false);
const add = (line: string) =>
setDraft((prev) => (prev.includes(line) ? prev : `${prev.trimEnd()}${prev.trim() ? '\n' : ''}${line}`));
const save = async () => {
setSaving(true);
const done = await memory.save(draft);
setSaving(false);
if (done) onClose();
};
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="narrow"
discardGuard={draft}
header={
<DialogHeader
tile={
<DialogTile>
<NotebookPen size={20} strokeWidth={2} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
eyebrow={t('assistant.launcher.tooltip')}
title={t('assistant.memory.title')}
sub={t('assistant.memory.sub')}
subWraps
onClose={onClose}
/>
}
footer={
<DialogFooter>
<span className="text-caption tabular-nums text-content-faint">
{draft.length}/{MAX_MEMORY_CHARS}
</span>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" disabled={saving} onClick={() => void save()}>
{t(saving ? 'common.saving' : 'common.save')}
</DialogButton>
</DialogFooter>
}
>
<DialogSection label={t('assistant.memory.quick')}>
<div className="flex flex-wrap gap-1.5">
{QUICK.map((key, i) => {
const line = t(`assistant.memory.quick.${key}`);
const taken = draft.includes(line);
return (
<button
key={key}
type="button"
disabled={taken}
onClick={() => add(line)}
className="trek-assistant-enter inline-flex items-center gap-1 rounded-full bg-surface-card px-2.5 py-1 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md disabled:translate-y-0 disabled:opacity-40 disabled:shadow-none"
style={{ animationDelay: `${i * 40}ms` }}
>
<Plus size={11} aria-hidden="true" />
{line}
</button>
);
})}
</div>
</DialogSection>
<DialogSection label={t('assistant.memory.notes')}>
<textarea
aria-label={t('assistant.memory.notes')}
rows={6}
maxLength={MAX_MEMORY_CHARS}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={t('assistant.memory.placeholder')}
className={TEXTAREA}
/>
</DialogSection>
</DialogShell>
);
}
@@ -1,133 +0,0 @@
import {
ComposerPrimitive,
type TriggerAdapter,
type TriggerItem,
unstable_useTriggerPopoverScopeContext as useTriggerScope,
} from '@assistant-ui/react';
import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Ticket } from 'lucide-react';
import { type MouseEvent, useEffect, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { MENTION_FORMATTER, MENTION_KINDS, type MentionKind } from './assistantMentionModel';
import { useAssistantView } from './assistantViewContext';
/** The icon of a day, place or booking in the `@` list. */
function MentionKindIcon({ kind, size }: { kind: string; size: number }) {
const props = { size, 'aria-hidden': true, className: 'flex-shrink-0' } as const;
if (kind === 'day') return <CalendarDays {...props} />;
if (kind === 'reservation') return <Ticket {...props} />;
return <MapPin {...props} />;
}
/** A row takes the click without the focus, so the caret stays in the text and typing goes on. */
const keepFocus = (e: MouseEvent) => e.preventDefault();
/**
* The list `@` opens over the composer: first the kinds (days, places,
* bookings), and once letters follow the `@`, the entries that match across
* all of them. The focus never leaves the text; arrows walk the list, Enter
* or Tab picks, Escape closes it and keeps what was typed.
*/
export default function AssistantMentionPopover({
adapter,
onInserted,
}: {
/** Undefined while the trip's lists are not at hand; `@` then opens nothing. */
adapter: TriggerAdapter | undefined;
onInserted: (item: TriggerItem) => void;
}) {
const { t } = useTranslation();
const { variant } = useAssistantView();
const phone = variant === 'fullscreen';
return (
<ComposerPrimitive.TriggerPopover
char="@"
adapter={adapter}
aria-label={t('assistant.mention.hint')}
className={`absolute bottom-full end-0 start-0 z-30 mb-1.5 overflow-y-auto rounded-xl border border-edge-faint bg-surface-card p-1 shadow-dropdown ${phone ? 'max-h-[40vh]' : 'max-h-56'}`}
>
<ComposerPrimitive.TriggerPopover.Directive formatter={MENTION_FORMATTER} onInserted={onInserted} />
<MentionList row={phone ? 'min-h-[44px]' : 'min-h-[32px]'} />
</ComposerPrimitive.TriggerPopover>
);
}
const ROW =
'flex w-full items-center gap-2 rounded-lg px-2 text-start text-body text-content transition-colors hover:bg-surface-hover data-[highlighted]:bg-surface-hover';
function MentionList({ row }: { row: string }) {
const { t } = useTranslation();
const scope = useTriggerScope();
const listRef = useRef<HTMLDivElement>(null);
// The highlighted row stays in view while the arrows walk past the list's edge.
useEffect(() => {
const hot = listRef.current?.querySelector<HTMLElement>('[data-highlighted]');
hot?.scrollIntoView?.({ block: 'nearest' });
}, [scope.highlightedIndex, scope.items, scope.activeCategoryId]);
if (!scope.open) return null;
const listing = scope.isSearchMode || scope.activeCategoryId !== null;
const active = scope.activeCategoryId ? MENTION_KINDS[scope.activeCategoryId as MentionKind] : undefined;
const heading = active ? t(active.labelKey) : null;
return (
<div ref={listRef}>
{heading && (
<ComposerPrimitive.TriggerPopoverBack
onMouseDown={keepFocus}
className="flex w-full items-center gap-1 rounded-lg px-1.5 py-1 text-caption font-semibold text-content-muted hover:text-content"
>
<ChevronLeft size={13} aria-hidden="true" className="rtl:-scale-x-100" />
{heading}
</ComposerPrimitive.TriggerPopoverBack>
)}
<ComposerPrimitive.TriggerPopoverCategories aria-label={t('assistant.mention.hint')}>
{(categories) =>
categories.map((c) => (
<ComposerPrimitive.TriggerPopoverCategoryItem
key={c.id}
categoryId={c.id}
onMouseDown={keepFocus}
className={`${ROW} ${row}`}
>
<span className="flex text-content-muted">
<MentionKindIcon kind={c.id} size={14} />
</span>
<span className="flex-1 truncate">{c.label}</span>
<ChevronRight
size={13}
aria-hidden="true"
className="flex-shrink-0 text-content-faint rtl:-scale-x-100"
/>
</ComposerPrimitive.TriggerPopoverCategoryItem>
))
}
</ComposerPrimitive.TriggerPopoverCategories>
<ComposerPrimitive.TriggerPopoverItems aria-label={heading ?? t('assistant.mention.hint')}>
{(items) =>
items.map((item, index) => (
<ComposerPrimitive.TriggerPopoverItem
key={item.id}
item={item}
index={index}
onMouseDown={keepFocus}
className={`${ROW} ${row}`}
>
<span className="flex text-content-muted">
<MentionKindIcon kind={item.type} size={14} />
</span>
<bdi className="min-w-0 flex-1 truncate">{item.label}</bdi>
{item.description && (
<span className="flex-shrink-0 text-caption text-content-faint">{item.description}</span>
)}
</ComposerPrimitive.TriggerPopoverItem>
))
}
</ComposerPrimitive.TriggerPopoverItems>
{listing && scope.items.length === 0 && (
<p className="m-0 px-2 py-1.5 text-caption text-content-muted">{t('assistant.mention.none')}</p>
)}
</div>
);
}
@@ -1,42 +0,0 @@
/**
* FE-AI-MENTION-TEXT-001 to FE-AI-MENTION-TEXT-002
*
* A sent question with the days, places and bookings it named set off as
* chips, and every other `@` left as text.
*/
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import AssistantMentionText from './AssistantMentionText';
describe('AssistantMentionText', () => {
it('FE-AI-MENTION-TEXT-001: sets off each named @label and keeps the words around it', () => {
const { container } = render(
<p>
<AssistantMentionText
text="Is @Louvre open on @Arrival?"
items={[
{ kind: 'place', id: 21, label: 'Louvre' },
{ kind: 'day', id: 11, label: 'Arrival' },
]}
/>
</p>
);
expect(container.querySelector('p')?.textContent).toBe('Is @Louvre open on @Arrival?');
expect(screen.getByText('@Louvre').tagName).toBe('BDI');
expect(screen.getByText('@Arrival').tagName).toBe('BDI');
});
it('FE-AI-MENTION-TEXT-002: an @ that names nothing stays plain text', () => {
const { container } = render(
<p>
<AssistantMentionText
text="Write to me @ home or @Louvre2"
items={[{ kind: 'place', id: 21, label: 'Louvre' }]}
/>
</p>
);
expect(container.querySelector('p')?.textContent).toBe('Write to me @ home or @Louvre2');
expect(container.querySelector('bdi')).toBeNull();
});
});
@@ -1,33 +0,0 @@
import type { AssistantMentions } from '@trek/shared';
import { useMemo } from 'react';
import { splitMentions } from './assistantMentionModel';
/**
* A sent question as the user wrote it, with every `@label` it named a day,
* place or booking with set off as a quiet inline chip. The labels come from
* the question's own mentions part, so a stray `@` in the text stays text.
*/
export default function AssistantMentionText({ text, items }: { text: string; items: AssistantMentions['items'] }) {
const segments = useMemo(
() =>
splitMentions(
text,
items.map((i) => i.label)
),
[text, items]
);
return (
<>
{segments.map((s) =>
s.mention ? (
<bdi key={s.at} className="rounded-md bg-surface-card px-1 font-medium text-content">
{s.text}
</bdi>
) : (
<span key={s.at}>{s.text}</span>
)
)}
</>
);
}
@@ -1,162 +0,0 @@
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
import type { AssistantMessage as StoredMessage } from '@trek/shared';
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { Tooltip } from '../shared/Tooltip';
import AssistantChangeCard from './AssistantChangeCard';
import AssistantFollowUps from './AssistantFollowUps';
import AssistantMarkdown from './AssistantMarkdown';
import AssistantMentionText from './AssistantMentionText';
import AssistantPlaceStrip from './AssistantPlaceStrip';
import AssistantShareDialog from './AssistantShareDialog';
import AssistantToolSteps from './AssistantToolSteps';
import AssistantWeatherStrip from './AssistantWeatherStrip';
import { messageBlocks } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { useAssistantShare } from './useAssistantShare';
/**
* One message of the chat. The user's question sits on the end side in a
* quiet accent bubble; the answer runs as plain text across the column, with
* its looked-up steps, the places and weather they found, and the proposed
* changes in between, and a copy and a share button that show on hover
* (always on the latest answer, and always on the phone, which has no hover).
*/
const BAR_BUTTON =
'rounded-md p-1 text-content-faint hover:bg-surface-hover hover:text-content-secondary disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent';
/** Posts the answer to the trip chat after a preview; a tick once it went. */
function ShareButton({ share }: { share: ReturnType<typeof useAssistantShare> }) {
const { t } = useTranslation();
if (share.shared) {
return (
<Tooltip label={t('assistant.share.shared')}>
<span className="inline-flex p-1 text-content-faint">
<Check size={14} aria-hidden="true" />
<span className="sr-only">{t('assistant.share.shared')}</span>
</span>
</Tooltip>
);
}
const label = share.offline ? t('assistant.error.offline') : t('assistant.share.button');
return (
<Tooltip label={label}>
{/* The wrapper carries the tooltip while the button is off. */}
<span className="inline-flex">
<button
type="button"
onClick={share.open}
disabled={share.offline || share.sending}
aria-label={t('assistant.share.button')}
className={BAR_BUTTON}
>
{share.sending ? (
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
) : (
<Share2 size={14} aria-hidden="true" />
)}
</button>
</span>
</Tooltip>
);
}
/** A file the user handed over, as a chip with what it is and how big. */
function AttachmentChip({ name, size }: { name: string; size: number }) {
const kb = Math.max(1, Math.round(size / 1024));
return (
<span className="inline-flex max-w-full items-center gap-2.5 rounded-xl border border-edge-faint bg-surface-card py-2 pe-3.5 ps-2 shadow-card">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-content-secondary">
<FileText size={16} aria-hidden="true" />
</span>
<span className="flex min-w-0 flex-col">
<bdi className="truncate text-body font-medium text-content">{name}</bdi>
<span className="text-caption tabular-nums text-content-muted">
{kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb} KB`}
</span>
</span>
</span>
);
}
function Question({ message }: { message: StoredMessage }) {
const text = message.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
const files = message.parts.flatMap((p) => (p.type === 'attachment' ? [p] : []));
const mentions = message.parts.flatMap((p) => (p.type === 'data-trek-mentions' ? p.data.items : []));
return (
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col items-end gap-1.5 py-1.5">
{files.map((f, i) => (
<AttachmentChip key={`${f.name}-${i}`} name={f.name} size={f.size} />
))}
{text && (
<p
dir="auto"
className="m-0 max-w-[85%] whitespace-pre-wrap break-words rounded-xl bg-accent-subtle px-3.5 py-2 text-body text-content"
>
{mentions.length > 0 ? <AssistantMentionText text={text} items={mentions} /> : text}
</p>
)}
</MessagePrimitive.Root>
);
}
function Answer({ message }: { message: StoredMessage }) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
const writing = useAssistantStore((s) => {
const chat = chatOf(s, tripId);
return chat.isRunning && chat.messages[chat.messages.length - 1]?.id === message.id;
});
const blocks = useMemo(() => messageBlocks(message, writing), [message, writing]);
const share = useAssistantShare(tripId, message, writing);
const last = blocks[blocks.length - 1];
const hasText = blocks.some((b) => b.kind === 'text');
return (
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
{blocks.map((block) => {
if (block.kind === 'text') {
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
}
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
if (block.kind === 'places') {
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
}
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
return <AssistantChangeCard key={block.key} items={block.items} />;
})}
{/* Nothing below says the answer goes on: no words being written, no step running. */}
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
<p role="status" className="m-0 text-caption">
<span className="trek-assistant-shimmer">{t('assistant.step.working')}</span>
</p>
)}
{hasText && !writing && (
// The bar mounts on hover for older answers; the row keeps its height so nothing jumps.
<div className="-mt-1 h-6">
<ActionBarPrimitive.Root autohide={variant === 'fullscreen' ? 'never' : 'not-last'} className="-ms-1 flex">
<ActionBarPrimitive.Copy aria-label={t('common.copy')} className={BAR_BUTTON}>
<AuiIf condition={(s) => s.message.isCopied}>
<Check size={14} aria-hidden="true" />
</AuiIf>
<AuiIf condition={(s) => !s.message.isCopied}>
<Copy size={14} aria-hidden="true" />
</AuiIf>
</ActionBarPrimitive.Copy>
{share.canShare && <ShareButton share={share} />}
</ActionBarPrimitive.Root>
</div>
)}
{share.canShare && <AssistantShareDialog share={share} />}
<AssistantFollowUps message={message} />
</MessagePrimitive.Root>
);
}
export default function AssistantMessage({ message }: { message: StoredMessage }) {
return message.role === 'user' ? <Question message={message} /> : <Answer message={message} />;
}
@@ -1,290 +0,0 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n';
import { getCategoryIcon } from '../shared/categoryIcons';
import { PopoverListPanel } from '../shared/dialogParts';
import { Tooltip } from '../shared/Tooltip';
import { usePopoverList } from '../shared/usePopoverList';
import { useAssistantView } from './assistantViewContext';
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
type Strip = ReturnType<typeof useAssistantPlaceStrip>;
const ACTION =
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
/** "+ Day 3" for the day in focus, a list of the trip's days beside it; "Pick a day" when no day is in focus. */
function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
const { t } = useTranslation();
const { days, focusDay, disabled, blockedKey, adding } = strip;
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
});
const { open, toggle, close, triggerRef, listId } = list;
const height = tall ? 'h-10' : 'h-7';
const busy = adding === place.ref;
const pick = (dayId: number) => {
close();
void strip.suggest(place, dayId);
};
const buttons = (
<span className="inline-flex min-w-0 gap-px">
{focusDay ? (
<>
<button
type="button"
disabled={disabled}
onClick={() => void strip.suggest(place, focusDay.id)}
aria-label={t('assistant.place.addTo', { day: focusDay.label })}
className={`${ACTION} ${height} rounded-e-none px-2`}
>
{busy ? (
<LoaderCircle size={12} aria-hidden="true" className="animate-spin" />
) : (
<Plus size={12} aria-hidden="true" />
)}
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
</button>
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-label={t('assistant.place.pickDay')}
className={`${ACTION} ${height} rounded-s-none px-1.5`}
>
<ChevronDown size={12} aria-hidden="true" />
</button>
</>
) : (
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={toggle}
aria-expanded={open}
aria-controls={open ? listId : undefined}
className={`${ACTION} ${height} px-2`}
>
<Plus size={12} aria-hidden="true" />
<span className="truncate">{t('assistant.place.pickDay')}</span>
<ChevronDown size={12} aria-hidden="true" className="text-content-faint" />
</button>
)}
</span>
);
return (
<>
{blockedKey ? (
<Tooltip label={t(blockedKey)} placement="top">
{/* A disabled button gets no pointer events; the wrapper carries the reason. */}
<span className="inline-flex min-w-0">{buttons}</span>
</Tooltip>
) : (
buttons
)}
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
<div className="grid grid-cols-7 gap-1">
{days.map((day) => {
const current = day.id === focusDay?.id;
return (
<Tooltip key={day.id} label={day.label} placement="top">
<button
type="button"
onClick={() => pick(day.id)}
aria-label={day.label}
aria-pressed={current}
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
}`}
>
<span className="text-body font-semibold tabular-nums">{day.n}</span>
{day.weekday && (
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
{day.weekday}
</span>
)}
</button>
</Tooltip>
);
})}
</div>
</PopoverListPanel>
</>
);
}
type Category = ReturnType<Strip['categoryOf']>;
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
function PlaceMedia({
place,
photo,
category,
}: {
place: AssistantFoundPlace;
photo: string | null;
category: Category;
}) {
const { t } = useTranslation();
const icon = category ? getCategoryIcon(category.icon) : MapPin;
return (
<span
className="relative block h-24 w-full overflow-hidden bg-surface-secondary"
// Without a picture the category's own colour washes the space, data from the trip.
style={
photo || !category
? undefined
: { background: `color-mix(in srgb, ${category.color} 16%, var(--bg-secondary))` }
}
>
{photo ? (
<img
src={photo}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-cover transition-transform duration-300 group-hover/card:scale-[1.04]"
/>
) : (
<span className="grid h-full w-full place-items-center">
{createElement(icon, {
size: 30,
'aria-hidden': true,
style: { color: category?.color ?? 'var(--text-faint)' },
})}
</span>
)}
{category && photo && (
<span className="absolute start-2 top-2 inline-flex max-w-[75%] items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content shadow-sm">
{createElement(icon, {
size: 11,
'aria-hidden': true,
className: 'flex-shrink-0',
style: { color: category.color },
})}
<bdi className="truncate">{category.name}</bdi>
</span>
)}
{place.openNow !== null && (
<span className="absolute bottom-2 end-2 inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content-muted shadow-sm">
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${place.openNow ? 'bg-success' : 'bg-[color:var(--text-faint)]'}`}
/>
{t(place.openNow ? 'assistant.place.openNow' : 'assistant.place.closed')}
</span>
)}
</span>
);
}
/** The card's one way on: "On map" for a place the trip has, "Suggested" once it is, else "+ Day 3". */
function PlaceAction({
place,
strip,
suggested,
tall,
}: {
place: AssistantFoundPlace;
strip: Strip;
suggested: boolean;
tall: boolean;
}): ReactNode {
const { t } = useTranslation();
if (place.inTripPlaceId !== null) {
return (
<Tooltip label={t('assistant.place.inTrip')} placement="top">
<button type="button" onClick={() => strip.show(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
<MapPin size={12} aria-hidden="true" />
{t('assistant.place.showOnMap')}
</button>
</Tooltip>
);
}
if (suggested) {
return (
<span className="inline-flex h-7 items-center gap-1 text-caption text-content-muted">
<Check size={12} aria-hidden="true" className="text-success" />
{t('assistant.place.suggested')}
</span>
);
}
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
}
/**
* One place a search found, as a card: its picture from TREK's photo proxy
* with the category it most likely belongs in on it (or that category's icon
* on its colour), its name, address and rating, whether it is open now while
* the trip runs, how far it is from the last stop of the day in focus, and one
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
* the card shows it on the map, and resting the pointer on it pins it there
* for a moment.
*/
export default function AssistantPlaceCard({
place,
strip,
suggested,
}: {
place: AssistantFoundPlace;
strip: Strip;
suggested: boolean;
}) {
const { t } = useTranslation();
const { variant, density } = useAssistantView();
const ref = useRef<HTMLLIElement>(null);
const photo = useFoundPlacePhoto(place, ref);
const category = strip.categoryOf(place);
const distance = strip.distanceOf(place);
const tall = variant === 'fullscreen';
return (
<li
ref={ref}
className={`group/card flex flex-shrink-0 snap-start flex-col overflow-hidden rounded-2xl border border-edge-faint bg-surface-card shadow-sm transition-shadow hover:shadow-md ${
density === 'compact' ? 'w-[188px]' : 'w-[220px]'
}`}
onMouseEnter={() => strip.hover(place)}
onMouseLeave={() => strip.hover(null)}
>
<button
type="button"
onClick={() => strip.show(place)}
onFocus={() => strip.hover(place)}
onBlur={() => strip.hover(null)}
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
>
<PlaceMedia place={place} photo={photo} category={category} />
<span className="flex min-w-0 flex-col gap-1 px-3 pt-2.5">
<bdi className="line-clamp-2 text-body font-semibold leading-snug text-content">{place.name}</bdi>
{category && !photo && <bdi className="truncate text-caption text-content-muted">{category.name}</bdi>}
{place.address && <bdi className="truncate text-caption text-content-muted">{place.address}</bdi>}
{(place.rating !== null || distance) && (
<span className="flex min-w-0 items-center gap-2 text-caption text-content-faint">
{place.rating !== null && (
<span className="inline-flex flex-shrink-0 items-center gap-0.5 font-medium text-content-muted">
<Star size={11} aria-hidden="true" className="fill-current text-warning" />
{place.rating.toFixed(1)}
</span>
)}
{distance && <span className="truncate">{t('assistant.place.fromPrev', { distance })}</span>}
</span>
)}
</span>
</button>
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
</div>
</li>
);
}
@@ -1,234 +0,0 @@
/**
* FE-AI-PLACES-001 to FE-AI-PLACES-008
*
* The strip of places a search found: what a card says, that its picture only
* ever comes from TREK's photo proxy, "+ Day N" for the day in focus or a
* picked one, why it waits while a change is undecided, and the map's part.
*/
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildAssignment,
buildCategory,
buildDay,
buildPlace,
buildTrip,
buildUser,
} from '../../../tests/helpers/factories';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { fetchPhoto } from '../../services/photoService';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { setForcedOffline } from '../../sync/networkMode';
import AssistantPlaceStrip from './AssistantPlaceStrip';
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
vi.mock('../../services/photoService', () => ({
fetchPhoto: vi.fn(),
getCached: vi.fn(() => undefined),
}));
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
const AUTH = useAuthStore.getState();
const RealObserver = globalThis.IntersectionObserver;
const DAY_ONE = buildDay({ id: 31, date: '2026-11-02' });
const DAY_TWO = buildDay({ id: 32, date: '2026-11-03' });
function place(ref: string, extra: Partial<AssistantFoundPlace> = {}): AssistantFoundPlace {
return {
ref,
name: `Trattoria ${ref}`,
address: null,
lat: 41.889,
lng: 12.47,
kind: 'food',
rating: null,
openNow: null,
googlePlaceId: null,
osmId: null,
photoKey: null,
inTripPlaceId: null,
...extra,
};
}
const answer = (parts: AssistantMessage['parts'] = []): AssistantMessage => ({
id: 'msg-5',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-21T08:00:00.000Z',
parts,
});
const addFoundPlace = vi.fn(async () => {});
const requestCamera = vi.fn();
const setPreviewPin = vi.fn();
const onRevealMap = vi.fn();
function seed({ focusDayId = 31, messages = [] as AssistantMessage[] } = {}) {
useAuthStore.setState({ user: buildUser({ id: 1 }), placesPhotosEnabled: true });
useTripStore.setState({
trip: buildTrip({ id: TRIP, user_id: 1 }),
days: [DAY_ONE, DAY_TWO],
assignments: {
'31': [buildAssignment({ order_index: 0, place: buildPlace({ lat: 41.9, lng: 12.47 }) })],
},
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
});
useAssistantStore.setState({
addFoundPlace,
requestCamera,
setPreviewPin,
byTrip: {
[TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7, focus: { dayId: focusDayId }, messages },
},
});
}
function renderStrip(found: AssistantPlaces, message = answer(), view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', density: 'compact', onRevealMap, ...view };
return render(
<AssistantViewContext.Provider value={value}>
<AssistantPlaceStrip found={found} message={message} />
</AssistantViewContext.Provider>
);
}
beforeEach(() => setForcedOffline(false));
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useTripStore.setState(TRIPS, true);
useAuthStore.setState(AUTH, true);
globalThis.IntersectionObserver = RealObserver;
setForcedOffline(false);
vi.clearAllMocks();
});
describe('AssistantPlaceStrip', () => {
it('FE-AI-PLACES-001: a card names the place, its likely category, whether it is open and how far it is', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0', { openNow: true })], more: 0 });
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
expect(screen.getByText('Restaurant')).toBeInTheDocument();
expect(screen.getByText('Open now')).toBeInTheDocument();
expect(screen.getByText('1.2 km from the previous stop')).toBeInTheDocument();
});
it('FE-AI-PLACES-002: shows every card it was sent and counts what the server left out on a tile', () => {
seed();
const list = Array.from({ length: 8 }, (_, i) => place(`s1:${i}`));
renderStrip({ toolCallId: 's1', places: list, more: 4 });
expect(screen.getAllByRole('button', { name: /^Add to/ })).toHaveLength(8);
expect(screen.getByText('4 more')).toBeInTheDocument();
});
it('FE-AI-PLACES-009: a place known only by its position shows its category, never a photo of the area', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0', { photoKey: 'coords:41.889000,12.470000' })], more: 0 });
expect(fetchPhoto).not.toHaveBeenCalled();
});
it('FE-AI-PLACES-003: a picture only ever comes from the photo proxy', async () => {
seed();
globalThis.IntersectionObserver = class {
private readonly callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
}
observe() {
this.callback([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
}
disconnect() {}
unobserve() {}
takeRecords() {
return [];
}
} as unknown as typeof IntersectionObserver;
vi.mocked(fetchPhoto).mockImplementation((key, _id, _lat, _lng, _name, callback) => {
const photoUrl = key === 'good' ? '/api/maps/place-photo/good/bytes' : 'https://evil.example/x.jpg';
callback?.({ photoUrl, thumbDataUrl: null });
});
const { container } = renderStrip({
toolCallId: 's1',
places: [place('s1:0', { photoKey: 'good' }), place('s1:1', { photoKey: 'bad', lat: 40 })],
more: 0,
});
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1));
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/maps/place-photo/good/bytes');
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
});
it('FE-AI-PLACES-004: "+ Day 1" suggests the place for the day in focus', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: /^Add to/ }));
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 31);
});
it('FE-AI-PLACES-005: another day is picked from the list, and without a focus the list is the only way', () => {
seed({ focusDayId: 0 });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: /Pick a day/ }));
fireEvent.click(screen.getByRole('button', { name: /Nov 3/ }));
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 32);
});
it('FE-AI-PLACES-006: waits while a change is undecided, and offline', () => {
const waiting = answer([
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
]);
seed({ messages: [waiting] });
const { unmount } = renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
unmount();
seed();
setForcedOffline(true);
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
});
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
seed();
const suggested = answer([
{
type: 'tool-call',
toolCallId: 'f1',
toolName: 'add_found_place',
args: { lat: 41.889, lng: 12.47 },
approval: { id: 'f1', approved: true },
},
]);
renderStrip(
{ toolCallId: 's1', places: [place('s1:0'), place('s1:1', { lat: 40, inTripPlaceId: 8 })], more: 0 },
suggested,
{ variant: 'fullscreen' }
);
expect(screen.getByText('Suggested')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'On map' }));
expect(onRevealMap).toHaveBeenCalled();
expect(requestCamera).toHaveBeenCalledWith(TRIP, [[40, 12.47]], 'pan', 'user');
});
it('FE-AI-PLACES-008: resting on a card pins it on the map until the pointer leaves', () => {
seed();
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
const card = screen.getByText('Trattoria s1:0').closest('li');
if (!card) throw new Error('no card');
fireEvent.mouseEnter(card);
expect(setPreviewPin).toHaveBeenLastCalledWith({
tripId: TRIP,
ref: 's1:0',
lat: 41.889,
lng: 12.47,
name: 'Trattoria s1:0',
});
fireEvent.mouseLeave(card);
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
});
});

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