mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
Compare commits
279 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72ff9600f4 | |||
| 5daa2a0311 | |||
| fa8a43b16e | |||
| aea37d9914 | |||
| 24b7e607c3 | |||
| bfec725e37 | |||
| 8a8ce4013b | |||
| 4abab7a777 | |||
| f8dc7ad011 | |||
| 680c52dc85 | |||
| e3d40ddcf6 | |||
| 00b50e280f | |||
| f8e29dd218 | |||
| 2fbff51299 | |||
| 55c6bad295 | |||
| 858c77422c | |||
| e5f1f573c5 | |||
| b721234249 | |||
| a1f97ba548 | |||
| 77ad0a06d0 | |||
| ea7120e141 | |||
| 7e563e1170 | |||
| 3c3a228737 | |||
| b6f7488591 | |||
| 26df56d76c | |||
| 214984f398 | |||
| bb3c0151d5 | |||
| 3101b36f3d | |||
| ef4eac34c4 | |||
| a518b8dfc2 | |||
| 8523bbb724 | |||
| 464d4e006c | |||
| 1797e1c70d | |||
| 5dc945145f | |||
| a1180b2b19 | |||
| d62fc284e1 | |||
| 8f53d98d45 | |||
| 9b71948497 | |||
| bb10b94046 | |||
| 94c369b913 | |||
| 3f1d1a57cc | |||
| 7c1de6ed82 | |||
| fbf6cd2840 | |||
| 5116f7be2c | |||
| 4f60cc5c98 | |||
| 65bb2d95eb | |||
| cbca3b2851 | |||
| 8a75712041 | |||
| 6e13f7e655 | |||
| 41f3267503 | |||
| 2a4a5617dc | |||
| 43353b9394 | |||
| 4eaf5e6a11 | |||
| 1a722e63fb | |||
| 924e38472e | |||
| bf2efca8f0 | |||
| 2377a63a05 | |||
| 377619abd1 | |||
| 988890866a | |||
| 0dda5bc3e9 | |||
| 7fa8b3fe35 | |||
| 112017a9e8 | |||
| cb5c5ab4a1 | |||
| bed9f53615 | |||
| a8e2486cd3 | |||
| 5d5015066f | |||
| 3e8d192305 | |||
| bafb74f872 | |||
| b71e32cf12 | |||
| 786df612fd | |||
| e6002444a1 | |||
| e453816ded | |||
| b9ca822d51 | |||
| 20537193b6 | |||
| 60611aee65 | |||
| 6c66cff365 | |||
| d3f574f225 | |||
| e5c5ffbc00 | |||
| 562176bd83 | |||
| ae699187f7 | |||
| 1d72fbd25f | |||
| 2d0c0597b3 | |||
| a85a50e186 | |||
| c8eb510bd4 | |||
| beea4c762b | |||
| d924dcf99d | |||
| 371083ed47 | |||
| eaeaf68352 | |||
| 5156fa2c0a | |||
| 6c6e285c3d | |||
| 93e1156aa4 | |||
| dc19807126 | |||
| 5580ff71a9 | |||
| e42622d71d | |||
| 94674e7572 | |||
| 7f14dde362 | |||
| 9765cf3fee | |||
| c38eb7ebf0 | |||
| e653733390 | |||
| 2655e510fc | |||
| 904eb31e62 | |||
| 76ec302526 | |||
| 810feba413 | |||
| 4009588078 | |||
| ef583ede18 | |||
| 9bc543a387 | |||
| 7291555e95 | |||
| 57f0e2f263 | |||
| de043cfb58 | |||
| 69df85bfa2 | |||
| be6702f3ab | |||
| 164a3633ac | |||
| 204ee2ef86 | |||
| baba2de344 | |||
| 49dd2db040 | |||
| 49e306ea48 | |||
| 1d7c8a63e2 | |||
| fb208a88e2 | |||
| 5ae8ac8e70 | |||
| 20bd384feb | |||
| 58302dc9e4 | |||
| b460cfd9a7 | |||
| 2b8d2e790f | |||
| 2fc9b1991a | |||
| 4d81180fc1 | |||
| 0f600d04f5 | |||
| b53257deaf | |||
| b284095ee0 | |||
| e40e576e39 | |||
| 4d9b84f681 | |||
| 223b056de2 | |||
| 981c372640 | |||
| 839d736905 | |||
| b9391a9921 | |||
| 5e0f216b0c | |||
| a249c861e6 | |||
| 2a40b884a1 | |||
| f5be556c27 | |||
| 6b1488994f | |||
| 2ee4ea1e65 | |||
| a1d683d7bc | |||
| ae726ed370 | |||
| f4750352e6 | |||
| 65ee472d66 | |||
| 73fa7aeb19 | |||
| fbba4fd73b | |||
| 2e1dca9fe9 | |||
| aacc7f2c8d | |||
| f833a05461 | |||
| 65fe8cd6e5 | |||
| 3fe34061fd | |||
| 3659297af1 | |||
| f57a0dbd31 | |||
| 3748bdb815 | |||
| 8fa75b3c4e | |||
| 6ec06c90f4 | |||
| 2449616ee9 | |||
| acc294dd8e | |||
| bde2ad2ca8 | |||
| ae79ab8dad | |||
| 6a0c54cb2f | |||
| a8be1c5b45 | |||
| d0cc6fc2f4 | |||
| f016972a4e | |||
| c80152f41d | |||
| f745ca13c9 | |||
| 4f68029b2c | |||
| 64445a5204 | |||
| bff9bcde92 | |||
| 3572f4b8a3 | |||
| ea9e997338 | |||
| ad2d653e9a | |||
| b177daea3c | |||
| 63a7a4d397 | |||
| f4533fc69b | |||
| 1993ed4d28 | |||
| 1969ca57d9 | |||
| b826ecd425 | |||
| 19946039ad | |||
| 6577595058 | |||
| 78366a9024 | |||
| 0331d9950f | |||
| dfe11e9c4c | |||
| fcdb7e48cd | |||
| 008aaeb408 | |||
| 5ff7e54e09 | |||
| 0169be3d79 | |||
| 68907ec811 | |||
| 51bfb5abd1 | |||
| 1d9b9bf838 | |||
| c83eb42730 | |||
| cd407cf80c | |||
| 71fe69c1f1 | |||
| 54baf85a2a | |||
| 3882feec5c | |||
| 6477bf5e39 | |||
| 971133a1eb | |||
| 0344db6d0c | |||
| 5d265c98de | |||
| 554d6f1b9f | |||
| f79b2020d6 | |||
| 9f0caf8539 | |||
| 7470a2c6d4 | |||
| 4ca5728072 | |||
| 2203ff3a2e | |||
| 0d7e3ceb3d | |||
| 93a508a0e6 | |||
| 0b92dcc86b | |||
| c836ee3988 | |||
| 188953158a | |||
| 20f8266439 | |||
| e5f01e570a | |||
| 2a8dbf91a8 | |||
| ec9e1a7533 | |||
| 3c823e07f4 | |||
| 9ed944c1e6 | |||
| 2955c61c86 | |||
| c97105ce61 | |||
| 290961cced | |||
| 4c594876ef | |||
| d5d2d6b25e | |||
| e3268735f2 | |||
| 9a59703a34 | |||
| 200ce58e98 | |||
| a965b2c75e | |||
| 91a834010b | |||
| a8d06193bd | |||
| 97b748555f | |||
| 187c4e2fc8 | |||
| dfd02940b5 | |||
| da0f79e285 | |||
| e877d35e0b | |||
| f3a39d9452 | |||
| b374fdc989 | |||
| b8eb2d909e | |||
| e429b49ef6 | |||
| 73237ffedb | |||
| adcaca3c5f | |||
| c3229afcba | |||
| 12e7bdf6e8 | |||
| 9382fa6dc6 | |||
| 3332495b7a | |||
| 75d8351fec | |||
| ce07c4dc72 | |||
| c5c88691bc | |||
| 827e682684 | |||
| 11a8d1b4d1 | |||
| 692373c8b8 | |||
| f6fd2722d0 | |||
| 9cd227fd81 | |||
| e4b49e1148 | |||
| a87ce65899 | |||
| 06e6e25b25 | |||
| d322363e53 | |||
| a20b682087 | |||
| ad86713fdc | |||
| 66525c66b7 | |||
| 7a484a09d4 | |||
| d72352911e | |||
| 134ab64bce | |||
| e29aa99269 | |||
| 7907963e03 | |||
| 45b9f07238 | |||
| d1ec6fb145 | |||
| 961d223c7c | |||
| faabc533c6 | |||
| bb39745a23 | |||
| c9e1e8ad86 | |||
| 9301053c56 | |||
| cda6e4bfd1 | |||
| dcc1c18fa9 | |||
| 7e9297d867 | |||
| a19d67e5a2 | |||
| cd64a2f542 | |||
| 1bc82f97fd | |||
| b76a298bb3 | |||
| 17029d364a | |||
| f458278e88 | |||
| fca42d46d9 |
@@ -26,5 +26,8 @@
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
# Ski area graphs (SGR1) as the Places host writes them, byte for byte
|
||||
*.sgr binary
|
||||
*.gz binary
|
||||
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
|
||||
server/tests/fixtures/gpx/bom-crlf.gpx -text
|
||||
|
||||
+339
-144
@@ -9,23 +9,73 @@ permissions:
|
||||
# analysis; pull requests get PR analysis with decoration on the PR itself
|
||||
# (the scanner picks the context up from the Actions environment).
|
||||
#
|
||||
# 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.)
|
||||
# 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.
|
||||
# - 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
|
||||
@@ -38,7 +88,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 preflight rather than after it.
|
||||
# compose files. Both run beside the gates rather than after them.
|
||||
# - 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,
|
||||
@@ -69,7 +119,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Changed Areas
|
||||
name: 'Repo: Changed Areas'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
@@ -144,36 +194,8 @@ 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: i18n Key Parity
|
||||
name: 'Shared: i18n Parity'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -189,8 +211,8 @@ jobs:
|
||||
- name: Check i18n key parity
|
||||
run: node shared/scripts/i18n-parity.mjs --strict
|
||||
|
||||
server-quality:
|
||||
name: Server Types & Lint
|
||||
server-types:
|
||||
name: 'Server: Types'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -239,8 +261,11 @@ 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.
|
||||
# catches it. src and tests together are past 570,000 lines and need
|
||||
# about 4.3 GB, over Node's default heap.
|
||||
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;
|
||||
@@ -257,6 +282,36 @@ 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
|
||||
@@ -264,8 +319,19 @@ 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
|
||||
@@ -359,12 +425,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-quality because it needs every workspace's
|
||||
# dependencies, which only this job installs.
|
||||
# rather than in client-types-checks because it needs every workspace's
|
||||
# dependencies, which the client jobs do not install.
|
||||
run: npm run lint:knip
|
||||
|
||||
client-quality:
|
||||
name: Client Types & Lint
|
||||
client-types-checks:
|
||||
name: 'Client: Types & Checks'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -380,7 +446,7 @@ jobs:
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace client
|
||||
run: npm ci --workspace shared --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
@@ -395,15 +461,6 @@ 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
|
||||
|
||||
@@ -467,36 +524,13 @@ jobs:
|
||||
# follow the user's scheme, accent and text size (scripts/theme-lint.mjs).
|
||||
run: cd client && npm run theme:lint
|
||||
|
||||
plugin-sdk-quality:
|
||||
name: Plugin SDK Types
|
||||
client-lint:
|
||||
name: 'Client: Lint'
|
||||
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
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
|
||||
# installs from its own directory.
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
client-bundle:
|
||||
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
|
||||
|
||||
@@ -507,7 +541,47 @@ jobs:
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace client
|
||||
run: npm ci --workspace shared --workspace client
|
||||
|
||||
- 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
|
||||
|
||||
client-bundle:
|
||||
name: 'Client: Bundle'
|
||||
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 shared --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
@@ -523,10 +597,11 @@ jobs:
|
||||
run: cd client && npm run check:gl-split
|
||||
|
||||
client-e2e:
|
||||
name: Client End-to-End
|
||||
name: 'Client: End-to-End'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
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
|
||||
|
||||
@@ -580,17 +655,17 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
s3-contract:
|
||||
name: S3 Driver Contract (MinIO)
|
||||
name: 'Server: S3 Contract (MinIO)'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
needs: changes
|
||||
# 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. (The implicit success() on this
|
||||
# expression also keeps the job skipped when a preflight fails.)
|
||||
# only those two are filtered out.
|
||||
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)
|
||||
|
||||
@@ -606,14 +681,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- 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: 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: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
@@ -671,10 +746,10 @@ jobs:
|
||||
# artifact seeds the file and a change that fixes a statement can lower it
|
||||
# without a local Postgres.
|
||||
postgres-probe:
|
||||
name: Postgres Probe
|
||||
name: 'Server: Postgres Probe'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
needs: [changes, plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
services:
|
||||
postgres:
|
||||
@@ -727,11 +802,20 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
server-coverage:
|
||||
name: Server Coverage
|
||||
# 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'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -744,20 +828,63 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- 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: 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: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd server && npm run test:coverage
|
||||
- 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: Upload coverage
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -766,11 +893,18 @@ jobs:
|
||||
path: server/coverage/
|
||||
retention-days: 1
|
||||
|
||||
client-coverage:
|
||||
name: Client Coverage
|
||||
# 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'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -785,13 +919,56 @@ 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 && npm ci --workspace client
|
||||
run: npm ci --workspace shared --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd client && npm run test:coverage
|
||||
- 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: Upload coverage
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -801,10 +978,11 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
shared-coverage:
|
||||
name: Shared Coverage
|
||||
name: 'Shared: Coverage'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
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
|
||||
|
||||
@@ -827,11 +1005,14 @@ jobs:
|
||||
path: shared/coverage/
|
||||
retention-days: 1
|
||||
|
||||
plugin-sdk-coverage:
|
||||
name: Plugin SDK Coverage
|
||||
# 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'
|
||||
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
|
||||
@@ -850,6 +1031,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
@@ -861,7 +1045,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
scan:
|
||||
name: Scan
|
||||
name: 'Repo: Sonar Scan'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
# Fork PRs and Dependabot runs don't receive SONAR_TOKEN, so skip the scan
|
||||
@@ -871,9 +1055,18 @@ 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 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]
|
||||
# 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
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -934,7 +1127,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: Docker Image
|
||||
name: 'Repo: Docker Image'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.image == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1011,7 +1204,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: Helm Chart & Compose
|
||||
name: 'Repo: Helm Chart & Compose'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deploy == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1082,19 +1275,21 @@ jobs:
|
||||
ci-ok:
|
||||
needs:
|
||||
- changes
|
||||
- plugin-facts
|
||||
- i18n-parity
|
||||
- server-quality
|
||||
- client-quality
|
||||
- plugin-sdk-quality
|
||||
- server-types
|
||||
- server-lint-checks
|
||||
- client-types-checks
|
||||
- client-lint
|
||||
- client-bundle
|
||||
- client-e2e
|
||||
- s3-contract
|
||||
- postgres-probe
|
||||
- server-tests
|
||||
- server-coverage
|
||||
- client-tests
|
||||
- client-coverage
|
||||
- shared-coverage
|
||||
- plugin-sdk-coverage
|
||||
- plugin-sdk
|
||||
- docker-image
|
||||
- deploy-manifests
|
||||
# always(): without it a failed dependency would skip this job, and the
|
||||
|
||||
@@ -55,6 +55,8 @@ coverage
|
||||
coverage-*/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
# Blob reports of a sharded vitest run (test.yml)
|
||||
.vitest-reports
|
||||
|
||||
# Cache
|
||||
.npm
|
||||
|
||||
@@ -134,8 +134,10 @@ 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-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.
|
||||
`.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.
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -102,8 +102,8 @@ env:
|
||||
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
|
||||
# Defaults to 25000.
|
||||
# TREK_PLACES_ENABLED: "false"
|
||||
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
|
||||
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
|
||||
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads,
|
||||
# and the OpenStreetMap ways and horse routes Tours reads) asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
|
||||
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
|
||||
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
|
||||
# here. Defaults to on.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { APIRequestContext, Browser, Page } from '@playwright/test';
|
||||
import { devices, expect, test } from '@playwright/test';
|
||||
import { E2E_STATE_FILE } from '../playwright.config';
|
||||
|
||||
// A stay booked somewhere else shows in the open planner without a reload. The
|
||||
// assistant books through its tools, and a stay it booked showed in the planner
|
||||
// only after a reload: the server told every tab, the client dropped the news.
|
||||
// Here a second session (a browser context of its own, so the open planner's
|
||||
// socket is not the one that asked) books the stay over the API, and the planner
|
||||
// has to show it on its own, on the desktop and on the phone.
|
||||
|
||||
/** A trip with three dated days, and their ids in order. */
|
||||
async function seedTrip(api: APIRequestContext, title: string) {
|
||||
const created = await api.post('/api/trips', {
|
||||
data: { title, start_date: '2099-07-01', end_date: '2099-07-03' },
|
||||
});
|
||||
expect(created.ok(), 'seed trip').toBeTruthy();
|
||||
const body = await created.json();
|
||||
const tripId: number = body.trip?.id ?? body.id;
|
||||
const days = await (await api.get(`/api/trips/${tripId}/days`)).json();
|
||||
const dayIds: number[] = (days.days ?? days).map((day: { id: number }) => day.id);
|
||||
expect(dayIds.length, 'the trip has its three days').toBe(3);
|
||||
return { tripId, dayIds };
|
||||
}
|
||||
|
||||
/** Opens the planner and waits until its socket joined the trip's room, so no event can slip past it. */
|
||||
async function openPlanner(page: Page, tripId: number) {
|
||||
const joined = new Promise<void>((resolve) => {
|
||||
page.on('websocket', (ws) => {
|
||||
ws.on('framereceived', (frame) => {
|
||||
if (String(frame.payload).includes('"joined"')) resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
await page.goto(`/trips/${tripId}`);
|
||||
await joined;
|
||||
}
|
||||
|
||||
/** Books a hotel from the first to the second day, from a session of its own. */
|
||||
async function bookElsewhere(browser: Browser, tripId: number, dayIds: number[], hotel: string) {
|
||||
const other = await browser.newContext({ storageState: E2E_STATE_FILE });
|
||||
try {
|
||||
const place = await other.request.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name: hotel, lat: 52.5306, lng: 13.3846 },
|
||||
});
|
||||
expect(place.ok(), 'the hotel place').toBeTruthy();
|
||||
const placeBody = await place.json();
|
||||
const stay = await other.request.post(`/api/trips/${tripId}/accommodations`, {
|
||||
data: { place_id: placeBody.place?.id ?? placeBody.id, start_day_id: dayIds[0], end_day_id: dayIds[1] },
|
||||
});
|
||||
expect(stay.ok(), 'the stay').toBeTruthy();
|
||||
} finally {
|
||||
await other.close();
|
||||
}
|
||||
}
|
||||
|
||||
test('desktop: a stay booked in another session shows in the day plan without a reload', async ({ page, browser }) => {
|
||||
const hotel = `Mercure Hotel ${Date.now()}`;
|
||||
const { tripId, dayIds } = await seedTrip(page.request, `Stay live ${Date.now()}`);
|
||||
await openPlanner(page, tripId);
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await bookElsewhere(browser, tripId, dayIds, hotel);
|
||||
|
||||
// The day head names the hotel of the night, read from the planner's stays.
|
||||
await expect(page.locator('[data-dp="day-pill"]', { hasText: hotel }).first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('phone: a stay booked in another session shows on the day plan without a reload', async ({ browser }) => {
|
||||
const pixel = devices['Pixel 7'];
|
||||
const phone = await browser.newContext({
|
||||
viewport: pixel.viewport,
|
||||
userAgent: pixel.userAgent,
|
||||
deviceScaleFactor: pixel.deviceScaleFactor,
|
||||
isMobile: pixel.isMobile,
|
||||
hasTouch: pixel.hasTouch,
|
||||
storageState: E2E_STATE_FILE,
|
||||
});
|
||||
try {
|
||||
const page = await phone.newPage();
|
||||
const hotel = `Mercure Hotel ${Date.now()}`;
|
||||
const { tripId, dayIds } = await seedTrip(page.request, `Stay live phone ${Date.now()}`);
|
||||
await openPlanner(page, tripId);
|
||||
await expect(page.locator('button[aria-current="true"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await bookElsewhere(browser, tripId, dayIds, hotel);
|
||||
|
||||
// The first day's plan carries a chip for the hotel checked into that night.
|
||||
await expect(page.locator('button', { hasText: hotel }).first()).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await phone.close();
|
||||
}
|
||||
});
|
||||
@@ -46,6 +46,7 @@
|
||||
"predev": "node scripts/patch-maplibre.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.3.0",
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 875,
|
||||
"@typescript-eslint/no-misused-promises": 870,
|
||||
"no-console": 88,
|
||||
"complexity": 230,
|
||||
"@typescript-eslint/no-explicit-any": 147,
|
||||
"trek/no-swallowed-catch": 133,
|
||||
"trek/no-swallowed-catch": 132,
|
||||
"max-params": 35,
|
||||
"trek/store-without-selector": 69,
|
||||
"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": 130,
|
||||
"react-hooks/refs": 557,
|
||||
"react-hooks/set-state-in-effect": 196,
|
||||
"@typescript-eslint/no-floating-promises": 166,
|
||||
"react-hooks/exhaustive-deps": 119,
|
||||
"@typescript-eslint/no-unused-vars": 127,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 15,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
@@ -35,11 +35,11 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 491,
|
||||
"@typescript-eslint/no-non-null-assertion": 1946,
|
||||
"@typescript-eslint/no-non-null-assertion": 1945,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
"trek/no-window-globals": 332,
|
||||
"trek/no-window-globals": 331,
|
||||
"trek/no-swallowed-catch": 14,
|
||||
"react-hooks/globals": 5,
|
||||
"react-hooks/refs": 22,
|
||||
|
||||
@@ -16,12 +16,17 @@
|
||||
"feature": "Addon manager",
|
||||
"hooks": [
|
||||
"src/components/Admin/addons/addonModel.ts",
|
||||
"src/components/Admin/addons/useAddonManager.ts",
|
||||
"src/components/Admin/addons/useLlmParsingConfig.ts"
|
||||
"src/components/Admin/addons/useAddonManager.ts"
|
||||
],
|
||||
"desktop": ["src/components/Admin/AddonManager.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Addon manager: AI tile",
|
||||
"hooks": ["src/components/Admin/addons/useAiAddonTile.ts"],
|
||||
"desktop": ["src/components/Admin/addons/AiAddonTile.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Admin notification preference matrix",
|
||||
"hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"],
|
||||
@@ -58,6 +63,21 @@
|
||||
"desktop": ["src/pages/admin/AdminUsersTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminUsersSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "AI assistant chat",
|
||||
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Assistant hints",
|
||||
"hooks": ["src/components/Assistant/useAssistantHints.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantHeader.tsx", "src/components/Assistant/AssistantLauncher.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/assistant/MAssistantScreen.tsx",
|
||||
"src/mobile/screens/trip/sheets/MMehrSheet.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Atlas country/region action sheet",
|
||||
"hooks": ["src/pages/atlas/useAtlasCountryActions.ts"],
|
||||
@@ -223,6 +243,15 @@
|
||||
"desktop": ["src/pages/CollectionsPage.tsx"],
|
||||
"mobile": ["src/mobile/screens/collections/MCollections.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Collection tours (LIB-03): useCollectionTours, useCollectionTourTakeOver",
|
||||
"hooks": [
|
||||
"src/components/Tours/collection/useCollectionTours.ts",
|
||||
"src/components/Tours/collection/useCollectionTourTakeOver.ts"
|
||||
],
|
||||
"desktop": ["src/components/Tours/collection/CollectionToursSection.tsx"],
|
||||
"mobile": ["src/mobile/screens/collections/MCollTours.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Copy collection places to a trip",
|
||||
"hooks": ["src/components/Collections/useTripCopyPicker.ts"],
|
||||
@@ -390,6 +419,18 @@
|
||||
"src/mobile/screens/journey/mobileJourneyMeta.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Journey day take-over: useJourneyDayImport",
|
||||
"hooks": ["src/components/Journey/useJourneyDayImport.ts"],
|
||||
"desktop": ["src/components/Journey/JourneyDayImportDialog.tsx"],
|
||||
"mobile": ["src/mobile/screens/journey/MJourneyDayImportSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Journey day recording: useJourneyRecordingDay",
|
||||
"hooks": ["src/components/Journey/useJourneyRecordingDay.ts"],
|
||||
"desktop": ["src/components/Journey/JourneyRecordingDialog.tsx"],
|
||||
"mobile": ["src/mobile/screens/journey/MJourneyRecordingSheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Journey detail page: useJourneyDetail",
|
||||
"hooks": ["src/pages/journeyDetail/useJourneyDetail.ts"],
|
||||
@@ -571,6 +612,24 @@
|
||||
"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"],
|
||||
@@ -613,6 +672,12 @@
|
||||
"desktop": ["src/components/Settings/DisplaySettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsGeneral.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Integrations / AI assistant",
|
||||
"hooks": ["src/components/Settings/ai/useAiConnections.ts"],
|
||||
"desktop": ["src/components/Settings/ai/AiConnectionSection.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MAiConnectionSection.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Integrations / AI parsing",
|
||||
"hooks": ["src/components/Settings/useLlmConnection.ts"],
|
||||
@@ -874,6 +939,7 @@
|
||||
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
|
||||
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
|
||||
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
|
||||
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
|
||||
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
||||
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
||||
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
|
||||
@@ -912,7 +978,6 @@
|
||||
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
|
||||
"src/mobile/screens/trip/sheets/MImportSheet.tsx": "Menu that routes to ImpFileStep and ImpListStep, with no logic of its own.",
|
||||
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
|
||||
"src/mobile/screens/trip/sheets/MMehrSheet.tsx": "Phone dock overflow menu that only opens tabs and sheets.",
|
||||
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
|
||||
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
|
||||
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
|
||||
|
||||
@@ -729,8 +729,6 @@
|
||||
"src/components/Planner/parsedItemToDraft.ts",
|
||||
"src/components/Planner/placeAmap.test.ts",
|
||||
"src/components/Planner/placeAmap.ts",
|
||||
"src/components/Planner/placeCategoryGuess.test.ts",
|
||||
"src/components/Planner/placeCategoryGuess.ts",
|
||||
"src/components/Planner/placeCoMaps.test.ts",
|
||||
"src/components/Planner/placeCoMaps.ts",
|
||||
"src/components/Planner/placeDialogParts.test.tsx",
|
||||
|
||||
@@ -139,6 +139,13 @@ export const DYNAMIC_ALLOWED = [
|
||||
'memories.* key in en',
|
||||
}))
|
||||
),
|
||||
{
|
||||
file: 'components/Assistant/AssistantChangeCard.tsx',
|
||||
template: 'assistant.tool.${tool}',
|
||||
because:
|
||||
'tool is the MCP tool a change card is for; verbLabel shows assistant.tool.<tool> only where en names ' +
|
||||
'that tool, and the generic add, change or remove verb for every other tool',
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"files.ts": 4,
|
||||
"help.ts": 3,
|
||||
"inspector.ts": 4,
|
||||
"journey.ts": 70,
|
||||
"journey.ts": 69,
|
||||
"login.ts": 1,
|
||||
"map.ts": 5,
|
||||
"memories.ts": 8,
|
||||
@@ -30,7 +30,7 @@
|
||||
"planner.ts": 53,
|
||||
"reservations.ts": 21,
|
||||
"roadtrip.ts": 38,
|
||||
"settings.ts": 40,
|
||||
"settings.ts": 37,
|
||||
"share.ts": 7,
|
||||
"shared.ts": 2,
|
||||
"storage.ts": 16,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 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 {
|
||||
@@ -137,6 +138,90 @@ 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);
|
||||
|
||||
/**
|
||||
@@ -148,6 +233,7 @@ 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,
|
||||
}) {
|
||||
@@ -160,13 +246,13 @@ export async function check({
|
||||
}
|
||||
|
||||
for (const line of errors) error(`FAIL ${line}`);
|
||||
let grown = 0;
|
||||
const grown = [];
|
||||
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++;
|
||||
grown.push({ area, rule });
|
||||
const top = Object.entries(files[area][rule])
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
@@ -182,7 +268,8 @@ export async function check({
|
||||
});
|
||||
stale += fallen.length;
|
||||
}
|
||||
if (grown) {
|
||||
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
|
||||
if (grown.length) {
|
||||
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.'
|
||||
@@ -191,5 +278,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 || stale ? 1 : 0;
|
||||
return errors.length || grown.length || stale ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -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": 1409,
|
||||
"src/components/Map/MapView.tsx": 1399,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2299,
|
||||
"src/components/Map/MapViewGL.tsx": 2291,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2971,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2963,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1193,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
"src/components/Studio/TravelElements.tsx": 1738,
|
||||
"src/components/Studio/autoLayout.ts": 1691,
|
||||
@@ -24,7 +24,7 @@
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1142,
|
||||
"src/pages/TripPlannerPage.tsx": 1140,
|
||||
"src/pages/atlas/useAtlas.ts": 1287,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||
|
||||
@@ -148,7 +148,6 @@
|
||||
"tests/integration/api/client.test.ts": 1,
|
||||
"tests/integration/hooks/useRouteCalculation.test.ts": 1,
|
||||
"tests/unit/i18n/i18nKeysScript.test.ts": 3,
|
||||
"tests/unit/mobile/admin/MAdminAddonManager.test.tsx": 2,
|
||||
"tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx": 1,
|
||||
"tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx": 1,
|
||||
"tests/unit/mobile/admin/MAdminStoragePanel.test.tsx": 1,
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
"mobile/components/MToastHost.tsx": 2,
|
||||
"mobile/components/MUserPickerList.tsx": 1,
|
||||
"mobile/screens/admin/MAdmin.tsx": 9,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 26,
|
||||
"mobile/screens/admin/MAdminAddonManager.tsx": 13,
|
||||
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
|
||||
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
|
||||
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
|
||||
|
||||
@@ -62,6 +62,8 @@ const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
|
||||
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
|
||||
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
|
||||
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
|
||||
// The desktop assistant window: its own chunk, and nothing at all until a connection is set up.
|
||||
const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost'))
|
||||
|
||||
// The ten phone screens are chunks of their own, alongside the desktop pages
|
||||
// rather than inside them. Each page used to import its M screen statically and
|
||||
@@ -403,6 +405,12 @@ export default function App() {
|
||||
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
{/* The phone has its own assistant screen inside the trip. */}
|
||||
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
|
||||
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
|
||||
<Suspense fallback={null}><AssistantHost /></Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* FE-AI-API-001 to FE-AI-API-007
|
||||
*
|
||||
* The two streaming calls bypass axios, so they carry by hand what the axios
|
||||
* instance would add (the cookie, the socket id) and turn a refusal before the
|
||||
* stream into a typed error the chat can name. The refusal's code comes from
|
||||
* the body when the server names one, and from the status otherwise.
|
||||
*/
|
||||
import type { AssistantChatRequest } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { assistantApi, AssistantRequestError } from './assistant';
|
||||
import { getSocketId } from './websocket';
|
||||
|
||||
const BODY: AssistantChatRequest = { locale: 'en', timeZone: 'Europe/Berlin', threadId: null, text: 'Hi' };
|
||||
|
||||
function streamResponse(text: string): Response {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let sent = false;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
if (sent) return { done: true, value: undefined };
|
||||
sent = true;
|
||||
return { done: false, value: bytes };
|
||||
},
|
||||
cancel: async () => {},
|
||||
}),
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function refusal(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
body: null,
|
||||
json: async () => {
|
||||
if (body === undefined) throw new SyntaxError('not json');
|
||||
return body;
|
||||
},
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
async function refusedWith(res: Response) {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(res);
|
||||
try {
|
||||
await assistantApi.chat(4, BODY, new AbortController().signal);
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
throw new Error('expected a refusal');
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('assistantApi streams', () => {
|
||||
it('FE-AI-API-001: posts the message as JSON with the cookie and the socket id, and yields the chunks', async () => {
|
||||
vi.mocked(getSocketId).mockReturnValueOnce('sock-1');
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(streamResponse('data: {"type":"start","messageId":"m1"}\n\ndata: [DONE]\n\n'));
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
for await (const chunk of await assistantApi.chat(4, BODY, signal)) chunks.push(chunk);
|
||||
|
||||
expect(chunks).toEqual([{ type: 'start', messageId: 'm1' }]);
|
||||
const [url, init] = fetchSpy.mock.calls[0] ?? [];
|
||||
expect(url).toBe('/api/trips/4/assistant/chat');
|
||||
expect(init).toMatchObject({ method: 'POST', credentials: 'include', signal, body: JSON.stringify(BODY) });
|
||||
expect(init?.headers).toMatchObject({
|
||||
'content-type': 'application/json',
|
||||
accept: 'text/event-stream',
|
||||
'X-Socket-Id': 'sock-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-API-002: sends decisions to the thread they belong to', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamResponse('data: [DONE]\n\n'));
|
||||
const body = { locale: 'de', timeZone: 'UTC', decisions: [{ toolCallId: 'c1', approved: true }] };
|
||||
await assistantApi.decide(4, 7, body, new AbortController().signal);
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe('/api/trips/4/assistant/threads/7/decisions');
|
||||
});
|
||||
|
||||
it('FE-AI-API-003: a refusal names the code the body gives', async () => {
|
||||
const err = await refusedWith(refusal(429, { error: 'Daily limit reached', code: 'daily_limit' }));
|
||||
expect(err).toBeInstanceOf(AssistantRequestError);
|
||||
expect(err).toMatchObject({ code: 'daily_limit', status: 429 });
|
||||
});
|
||||
|
||||
it('FE-AI-API-004: without a known code the status decides', async () => {
|
||||
expect(await refusedWith(refusal(429, { error: 'slow down' }))).toMatchObject({ code: 'too_many_requests' });
|
||||
expect(await refusedWith(refusal(409, { error: 'x', code: 'conflict' }))).toMatchObject({ code: 'busy' });
|
||||
expect(await refusedWith(refusal(502, undefined))).toMatchObject({ code: 'provider_unavailable' });
|
||||
expect(await refusedWith(refusal(500, { error: 'boom' }))).toMatchObject({ code: 'generic' });
|
||||
});
|
||||
|
||||
it('FE-AI-API-005: an accepted request without a body is a generic failure, not a silent empty answer', async () => {
|
||||
const err = await refusedWith({ ok: true, status: 200, body: null } as unknown as Response);
|
||||
expect(err).toMatchObject({ code: 'generic', status: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantApi JSON calls', () => {
|
||||
it('FE-AI-API-006: undo posts an empty JSON body to the escaped tool call id', async () => {
|
||||
let seen: { path: string; body: unknown } | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/4/assistant/tool-calls/:id/undo', async ({ request, params }) => {
|
||||
seen = { path: String(params.id), body: await request.json() };
|
||||
return HttpResponse.json({ undone: true });
|
||||
})
|
||||
);
|
||||
await expect(assistantApi.undo(4, 'a/b')).resolves.toEqual({ undone: true });
|
||||
expect(seen).toEqual({ path: 'a/b', body: {} });
|
||||
});
|
||||
|
||||
it('FE-AI-API-007: stop posts an empty JSON body', async () => {
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
http.post('/api/trips/4/assistant/stop', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
await expect(assistantApi.stop(4)).resolves.toEqual({ success: true });
|
||||
expect(body).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
ASSISTANT_ERROR_CODES,
|
||||
type AiAdminConnectionResponse,
|
||||
type AiAdminSettings,
|
||||
type AiApiKeyConnectRequest,
|
||||
type AiAuthStartResponse,
|
||||
type AiChatgptFinishRequest,
|
||||
type AiClaudeConnectorResponse,
|
||||
type AiConnectionScope,
|
||||
type AiConnectionView,
|
||||
type AiInstanceConnectionRequest,
|
||||
type AiModelListResponse,
|
||||
type AiOpenrouterFinishRequest,
|
||||
type AiPrivacyAckRequest,
|
||||
type AiStatusResponse,
|
||||
type AssistantAddFoundPlaceRequest,
|
||||
type AssistantAddFoundPlaceResponse,
|
||||
type AssistantChatRequest,
|
||||
type AssistantDecisionsRequest,
|
||||
type AssistantErrorCode,
|
||||
type AssistantShareRequest,
|
||||
type AssistantThreadListResponse,
|
||||
type AssistantThreadResponse,
|
||||
type AssistantUndoResponse,
|
||||
type CollabMessageResponse,
|
||||
type SuccessResponse,
|
||||
type UiStreamChunk,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
import { parseUiMessageStream } from './uiMessageStream';
|
||||
import { getSocketId } from './websocket';
|
||||
|
||||
/**
|
||||
* The trip assistant and the AI connections behind it, in their own file
|
||||
* rather than in the `client.ts` god module.
|
||||
*
|
||||
* The two streaming calls (a new message, and the decisions on held changes)
|
||||
* go through `fetch`, not axios: the answer is a server-sent event stream that
|
||||
* runs for as long as the model works, and axios would buffer it whole behind
|
||||
* its 8 s timeout. Everything else is plain JSON through `apiClient`.
|
||||
*/
|
||||
|
||||
/** A refusal the server answered before the stream opened, named by its `AssistantErrorCode`. */
|
||||
export class AssistantRequestError extends Error {
|
||||
readonly code: AssistantErrorCode;
|
||||
readonly status: number;
|
||||
|
||||
constructor(code: AssistantErrorCode, status: number) {
|
||||
super(`Assistant request refused: ${code}`);
|
||||
this.name = 'AssistantRequestError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_CODES: ReadonlySet<string> = new Set(ASSISTANT_ERROR_CODES);
|
||||
|
||||
/**
|
||||
* The code of a refusal: the body's `code` when it names one, otherwise what
|
||||
* the status alone can say.
|
||||
*/
|
||||
async function refusalCode(res: Response): Promise<AssistantErrorCode> {
|
||||
try {
|
||||
const body: unknown = await res.json();
|
||||
const code = (body as { code?: unknown } | null)?.code;
|
||||
if (typeof code === 'string' && KNOWN_CODES.has(code)) return code as AssistantErrorCode;
|
||||
} catch {
|
||||
// Not JSON (a proxy's error page); the status below still says something.
|
||||
}
|
||||
// A bare 429 is TREK's own limit or a proxy's; a provider's limit always names its code.
|
||||
if (res.status === 429) return 'too_many_requests';
|
||||
if (res.status === 409) return 'busy';
|
||||
if (res.status === 502 || res.status === 503 || res.status === 504) return 'provider_unavailable';
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
async function openStream(path: string, body: unknown, signal: AbortSignal): Promise<AsyncGenerator<UiStreamChunk>> {
|
||||
// A file goes as multipart, where the browser sets the content type with its boundary.
|
||||
const form = body instanceof FormData;
|
||||
const headers: Record<string, string> = form
|
||||
? { accept: 'text/event-stream' }
|
||||
: { 'content-type': 'application/json', accept: 'text/event-stream' };
|
||||
const socketId = getSocketId();
|
||||
if (socketId) headers['X-Socket-Id'] = socketId;
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: form ? body : JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new AssistantRequestError(await refusalCode(res), res.status);
|
||||
if (!res.body) throw new AssistantRequestError('generic', res.status);
|
||||
return parseUiMessageStream(res.body);
|
||||
}
|
||||
|
||||
const tripBase = (tripId: number) => `/trips/${tripId}/assistant`;
|
||||
|
||||
/**
|
||||
* For the calls that make the server go and ask the provider (listing models,
|
||||
* testing a key, exchanging a sign-in code): the shared 8 s is TREK's own
|
||||
* budget and too short for a provider's.
|
||||
*/
|
||||
const viaProvider = { timeout: 30_000 };
|
||||
|
||||
export const assistantApi = {
|
||||
threads: (tripId: number): Promise<AssistantThreadListResponse> =>
|
||||
apiClient.get(`${tripBase(tripId)}/threads`).then((r) => r.data),
|
||||
thread: (tripId: number, threadId: number): Promise<AssistantThreadResponse> =>
|
||||
apiClient.get(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
|
||||
deleteThread: (tripId: number, threadId: number): Promise<SuccessResponse> =>
|
||||
apiClient.delete(`${tripBase(tripId)}/threads/${threadId}`).then((r) => r.data),
|
||||
undo: (tripId: number, toolCallId: string): Promise<AssistantUndoResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/tool-calls/${encodeURIComponent(toolCallId)}/undo`, {}).then((r) => r.data),
|
||||
// Ends this user's running turn, wherever it runs (another tab included).
|
||||
stop: (tripId: number): Promise<SuccessResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/stop`, {}).then((r) => r.data),
|
||||
// A place a search found, suggested for a day: answers with the message holding the change.
|
||||
addFoundPlace: (
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
body: AssistantAddFoundPlaceRequest
|
||||
): Promise<AssistantAddFoundPlaceResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
|
||||
// An answer posted to the trip chat, marked as shared from the assistant; the client's socket id skips the echo.
|
||||
share: (
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
messageId: string,
|
||||
body: AssistantShareRequest
|
||||
): Promise<CollabMessageResponse> =>
|
||||
apiClient
|
||||
.post(`${tripBase(tripId)}/threads/${threadId}/messages/${encodeURIComponent(messageId)}/share`, body)
|
||||
.then((r) => r.data),
|
||||
|
||||
chat: (tripId: number, body: AssistantChatRequest, signal: AbortSignal) =>
|
||||
openStream(`${tripBase(tripId)}/chat`, body, signal),
|
||||
decide: (tripId: number, threadId: number, body: AssistantDecisionsRequest, signal: AbortSignal) =>
|
||||
openStream(`${tripBase(tripId)}/threads/${threadId}/decisions`, body, signal),
|
||||
// A booking file: the server reads it and proposes each booking it finds as a change.
|
||||
attach: (tripId: number, form: FormData, signal: AbortSignal) =>
|
||||
openStream(`${tripBase(tripId)}/attachments`, form, signal),
|
||||
};
|
||||
|
||||
/** A user's own connection, and what the assistant runs on for them. */
|
||||
export const aiApi = {
|
||||
status: (): Promise<AiStatusResponse> => apiClient.get('/ai/status').then((r) => r.data),
|
||||
models: (): Promise<AiModelListResponse> => apiClient.get('/ai/models', viaProvider).then((r) => r.data),
|
||||
// Saving tests the key against the provider first.
|
||||
connectKey: (body: AiApiKeyConnectRequest): Promise<AiConnectionView> =>
|
||||
apiClient.put('/ai/connection', body, viaProvider).then((r) => r.data),
|
||||
setModel: (model: string): Promise<AiConnectionView> =>
|
||||
apiClient.patch('/ai/connection/model', { model }).then((r) => r.data),
|
||||
disconnect: (): Promise<SuccessResponse> => apiClient.delete('/ai/connection').then((r) => r.data),
|
||||
ackPrivacy: (body: AiPrivacyAckRequest): Promise<SuccessResponse> =>
|
||||
apiClient.post('/ai/privacy-ack', body).then((r) => r.data),
|
||||
|
||||
chatgptStart: (scope: AiConnectionScope = 'user'): Promise<AiAuthStartResponse> =>
|
||||
apiClient
|
||||
.post(scope === 'instance' ? '/admin/ai/connect/chatgpt/start' : '/ai/connect/chatgpt/start', {})
|
||||
.then((r) => r.data),
|
||||
chatgptFinish: (body: AiChatgptFinishRequest): Promise<AiConnectionView> =>
|
||||
apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data),
|
||||
chatgptSignOut: (): Promise<SuccessResponse> =>
|
||||
apiClient.post('/ai/connect/chatgpt/sign-out', {}).then((r) => r.data),
|
||||
|
||||
openrouterStart: (scope: AiConnectionScope): Promise<AiAuthStartResponse> =>
|
||||
apiClient
|
||||
.post(scope === 'instance' ? '/admin/ai/connect/openrouter/start' : '/ai/connect/openrouter/start', { scope })
|
||||
.then((r) => r.data),
|
||||
openrouterFinish: (body: AiOpenrouterFinishRequest): Promise<AiConnectionView> =>
|
||||
apiClient.post('/ai/connect/openrouter/finish', body, viaProvider).then((r) => r.data),
|
||||
};
|
||||
|
||||
/** The connection the instance provides for everyone, and the assistant's admin settings. */
|
||||
export const aiAdminApi = {
|
||||
settings: (): Promise<AiAdminSettings> => apiClient.get('/admin/ai/settings').then((r) => r.data),
|
||||
saveSettings: (body: AiAdminSettings): Promise<AiAdminSettings> =>
|
||||
apiClient.put('/admin/ai/settings', body).then((r) => r.data),
|
||||
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
|
||||
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
|
||||
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
|
||||
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
|
||||
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const claudeConnectorApi = {
|
||||
get: (): Promise<AiClaudeConnectorResponse> => apiClient.get('/integrations/claude-connector').then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// FE-API-JOURNEY-MAP-001
|
||||
//
|
||||
// JRN-04: both reads pin their path and hand back what the server answered.
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { journeyMapApi } from './journeyMap';
|
||||
|
||||
describe('journeyMapApi', () => {
|
||||
it('FE-API-JOURNEY-MAP-001: reads the layers of a journey and those of a share link', async () => {
|
||||
const layers = { places: { status: 'withheld' } };
|
||||
const paths: string[] = [];
|
||||
server.use(
|
||||
http.get('/api/journeys/7/map-layers', ({ request }) => {
|
||||
paths.push(new URL(request.url).pathname);
|
||||
return HttpResponse.json(layers);
|
||||
}),
|
||||
http.get('/api/public/journey/:token/map-layers', ({ request }) => {
|
||||
paths.push(new URL(request.url).pathname);
|
||||
return HttpResponse.json(layers);
|
||||
})
|
||||
);
|
||||
expect(await journeyMapApi.layers(7)).toEqual(layers);
|
||||
expect(await journeyMapApi.publicLayers('a/b')).toEqual(layers);
|
||||
expect(paths).toEqual(['/api/journeys/7/map-layers', '/api/public/journey/a%2Fb/map-layers']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { JourneyMapLayersResponse, JourneyPublicMapLayersResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** JRN-04: what a journey map shows beside its entries and photos, for its people and through a share link. */
|
||||
export const journeyMapApi = {
|
||||
layers: (journeyId: number, signal?: AbortSignal): Promise<JourneyMapLayersResponse> =>
|
||||
apiClient.get(`/journeys/${journeyId}/map-layers`, { signal }).then((r) => r.data),
|
||||
publicLayers: (token: string, signal?: AbortSignal): Promise<JourneyPublicMapLayersResponse> =>
|
||||
apiClient.get(`/public/journey/${encodeURIComponent(token)}/map-layers`, { signal }).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
// FE-API-JOURNEY-RECORDINGS-001
|
||||
//
|
||||
// JRN-01: every method pins its verb, its path and its body, and hands back what
|
||||
// the server answered.
|
||||
import { http, HttpResponse, type JsonBodyType } from 'msw';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { journeyRecordingsApi } from './journeyRecordings';
|
||||
|
||||
const BASE = '/api/journeys/7/recordings';
|
||||
const HASH = 'a'.repeat(64);
|
||||
|
||||
let calls: Array<{ method: string; path: string; body: unknown }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
});
|
||||
|
||||
function record(data: JsonBodyType) {
|
||||
return async ({ request }: { request: Request }) => {
|
||||
const text = await request.text();
|
||||
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
|
||||
return HttpResponse.json(data);
|
||||
};
|
||||
}
|
||||
|
||||
describe('journeyRecordingsApi', () => {
|
||||
it('FE-API-JOURNEY-RECORDINGS-001: lists, previews, keeps and removes the recorded days of a journey', async () => {
|
||||
server.use(
|
||||
http.get(BASE, record({ days: [] })),
|
||||
http.post(`${BASE}/preview`, record({ date: '2026-05-14' })),
|
||||
http.put(`${BASE}/2026-05-14`, record({ outcome: 'created' })),
|
||||
http.delete(`${BASE}/2026-05-14`, record({ success: true }))
|
||||
);
|
||||
expect(await journeyRecordingsApi.list(7)).toEqual({ days: [] });
|
||||
expect(
|
||||
await journeyRecordingsApi.preview(7, { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true })
|
||||
).toEqual({ date: '2026-05-14' });
|
||||
expect(await journeyRecordingsApi.save(7, '2026-05-14', { utcOffsetMinutes: 120, expectedHash: HASH })).toEqual({
|
||||
outcome: 'created',
|
||||
});
|
||||
expect(await journeyRecordingsApi.remove(7, '2026-05-14')).toBeUndefined();
|
||||
expect(calls).toEqual([
|
||||
{ method: 'GET', path: BASE, body: null },
|
||||
{
|
||||
method: 'POST',
|
||||
path: `${BASE}/preview`,
|
||||
body: { date: '2026-05-14', utcOffsetMinutes: 120, useSourceOffset: true },
|
||||
},
|
||||
{ method: 'PUT', path: `${BASE}/2026-05-14`, body: { utcOffsetMinutes: 120, expectedHash: HASH } },
|
||||
{ method: 'DELETE', path: `${BASE}/2026-05-14`, body: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-API-JOURNEY-RECORDINGS-002: reviews the stays of a day and takes them over (JRN-02)', async () => {
|
||||
const body = { groups: [{ kind: 'single' as const, stays: [{ suggestionId: 3 }] }], skipSuggestionIds: [4] };
|
||||
server.use(
|
||||
http.post(`${BASE}/2026-05-14/stays/review`, record({ date: '2026-05-14', stays: [] })),
|
||||
http.post(`${BASE}/2026-05-14/stays/import`, record({ entries: [], alreadyTaken: [3], skipped: [] }))
|
||||
);
|
||||
expect(await journeyRecordingsApi.reviewDay(7, '2026-05-14')).toEqual({ date: '2026-05-14', stays: [] });
|
||||
expect(await journeyRecordingsApi.takeOverDay(7, '2026-05-14', body)).toEqual({
|
||||
entries: [],
|
||||
alreadyTaken: [3],
|
||||
skipped: [],
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{ method: 'POST', path: `${BASE}/2026-05-14/stays/review`, body: {} },
|
||||
{ method: 'POST', path: `${BASE}/2026-05-14/stays/import`, body },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-API-JOURNEY-RECORDINGS-003: reads how the journey prepares its days (JRN-03)', async () => {
|
||||
const answer = {
|
||||
enabled: false,
|
||||
connection: 'not_connected',
|
||||
lastAttemptAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
server.use(http.get(`${BASE}/prep`, record(answer)));
|
||||
expect(await journeyRecordingsApi.prep(7)).toEqual(answer);
|
||||
expect(calls).toEqual([{ method: 'GET', path: `${BASE}/prep`, body: null }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type {
|
||||
JourneyDayImportRequest,
|
||||
JourneyDayImportResult,
|
||||
JourneyDayStays,
|
||||
JourneyRecordedDaysResponse,
|
||||
JourneyRecordingPrep,
|
||||
JourneyRecordingPreview,
|
||||
JourneyRecordingPreviewRequest,
|
||||
JourneyRecordingSaveRequest,
|
||||
JourneyRecordingSaveResult,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
import { DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich';
|
||||
|
||||
const base = (journeyId: number) => `/journeys/${journeyId}/recordings`;
|
||||
|
||||
/** The preview and the save ask Dawarich first, so they get its longer wait. */
|
||||
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS };
|
||||
|
||||
/**
|
||||
* JRN-01: the recorded days a journey keeps, previewing one, keeping it and dropping it.
|
||||
* JRN-02: the stays of a day reviewed and taken over as drafts.
|
||||
* JRN-03: how the journey prepares its days for the caller.
|
||||
*/
|
||||
export const journeyRecordingsApi = {
|
||||
list: (journeyId: number, signal?: AbortSignal): Promise<JourneyRecordedDaysResponse> =>
|
||||
apiClient.get(base(journeyId), { signal }).then((r) => r.data),
|
||||
prep: (journeyId: number, signal?: AbortSignal): Promise<JourneyRecordingPrep> =>
|
||||
apiClient.get(`${base(journeyId)}/prep`, { signal }).then((r) => r.data),
|
||||
preview: (
|
||||
journeyId: number,
|
||||
body: JourneyRecordingPreviewRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<JourneyRecordingPreview> =>
|
||||
apiClient.post(`${base(journeyId)}/preview`, body, { ...upstream, signal }).then((r) => r.data),
|
||||
save: (journeyId: number, date: string, body: JourneyRecordingSaveRequest): Promise<JourneyRecordingSaveResult> =>
|
||||
apiClient.put(`${base(journeyId)}/${date}`, body, upstream).then((r) => r.data),
|
||||
remove: (journeyId: number, date: string): Promise<void> =>
|
||||
apiClient.delete(`${base(journeyId)}/${date}`).then(() => undefined),
|
||||
reviewDay: (journeyId: number, date: string, signal?: AbortSignal): Promise<JourneyDayStays> =>
|
||||
apiClient.post(`${base(journeyId)}/${date}/stays/review`, {}, { ...upstream, signal }).then((r) => r.data),
|
||||
takeOverDay: (journeyId: number, date: string, body: JourneyDayImportRequest): Promise<JourneyDayImportResult> =>
|
||||
apiClient.post(`${base(journeyId)}/${date}/stays/import`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
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,17 +2,19 @@ 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. */
|
||||
/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */
|
||||
export const paddleRegionsApi = {
|
||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
|
||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
|
||||
catalog: (q: string, signal?: AbortSignal) =>
|
||||
apiClient
|
||||
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
|
||||
@@ -29,4 +31,11 @@ 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)),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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)),
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { skiRouteResponseSchema, type SkiRouteInput, type SkiRouteResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** The piste router: a ski tour's legs planned on the piste data the server keeps, held to the contract. */
|
||||
export const skiRouteApi = {
|
||||
plan: (tripId: number | string, request: SkiRouteInput, signal?: AbortSignal): Promise<SkiRouteResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/ski/route`, request, { signal }).then((r) => skiRouteResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
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');
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
|
||||
import { postMultipart } from './client';
|
||||
|
||||
/** A GPX file and the fields beside it, as the Tours import routes read a multipart body. */
|
||||
/** A GPX, KML or KMZ 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,7 +9,10 @@ function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
||||
return fd;
|
||||
}
|
||||
|
||||
/** The Tours GPX import: a file read for its preview first (PAD-02), then imported as the type chosen. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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> =>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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']) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
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),
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* FE-AI-STREAM-001 to FE-AI-STREAM-011
|
||||
*
|
||||
* The parser reads the server's assistant stream, so its contract is the
|
||||
* shared fixture: the same file the server's writer is tested against. What
|
||||
* the network does to that stream (cutting it at any byte, CRLF from a proxy,
|
||||
* keep-alive comments) must not change what comes out.
|
||||
*/
|
||||
import { uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MAX_EVENT_CHARS, parseUiMessageStream, STALL_MS } from './uiMessageStream';
|
||||
|
||||
const FIXTURE = readFileSync(
|
||||
resolve(dirname(fileURLToPath(import.meta.url)), '../../../shared/src/assistant/__fixtures__/ui-stream.v1.txt'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
/** A response body that hands out the given pieces one read at a time. */
|
||||
function bodyOf(pieces: (string | Uint8Array)[]) {
|
||||
const encoder = new TextEncoder();
|
||||
let i = 0;
|
||||
const cancel = vi.fn(async () => {});
|
||||
const body = {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
if (i >= pieces.length) return { done: true, value: undefined };
|
||||
const piece = pieces[i++];
|
||||
return { done: false, value: typeof piece === 'string' ? encoder.encode(piece) : piece };
|
||||
},
|
||||
cancel,
|
||||
}),
|
||||
} as unknown as ReadableStream<Uint8Array>;
|
||||
return { body, cancel };
|
||||
}
|
||||
|
||||
async function collect(body: ReadableStream<Uint8Array>): Promise<UiStreamChunk[]> {
|
||||
const chunks: UiStreamChunk[] = [];
|
||||
for await (const chunk of parseUiMessageStream(body)) chunks.push(chunk);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/** The fixture's chunks as plain JSON, the way a reader of the file sees them. */
|
||||
function fixtureChunks(): UiStreamChunk[] {
|
||||
return FIXTURE.split('\n\n')
|
||||
.map((event) => event.replace(/^data: /, '').trim())
|
||||
.filter((data) => data && data !== '[DONE]')
|
||||
.map((data) => uiStreamChunkSchema.parse(JSON.parse(data)));
|
||||
}
|
||||
|
||||
describe('parseUiMessageStream', () => {
|
||||
it('FE-AI-STREAM-001: reads the shared fixture into its chunks, in order', async () => {
|
||||
const chunks = await collect(bodyOf([FIXTURE]).body);
|
||||
expect(chunks).toEqual(fixtureChunks());
|
||||
expect(chunks.map((c) => c.type)).toContain('tool-approval-request');
|
||||
expect(chunks[chunks.length - 1]).toEqual({ type: 'finish', finishReason: 'tool-calls' });
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-002: gives the same chunks when every byte arrives in its own read', async () => {
|
||||
const bytes = new TextEncoder().encode(FIXTURE);
|
||||
const pieces = Array.from(bytes, (b) => new Uint8Array([b]));
|
||||
expect(await collect(bodyOf(pieces).body)).toEqual(fixtureChunks());
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-003: reads CRLF line ends, also when a CR and its LF arrive apart', async () => {
|
||||
const crlf = FIXTURE.replace(/\n/g, '\r\n');
|
||||
const cut = crlf.indexOf('\r\n') + 1;
|
||||
expect(await collect(bodyOf([crlf.slice(0, cut), crlf.slice(cut)]).body)).toEqual(fixtureChunks());
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-004: skips keep-alive comments and fields other than data', async () => {
|
||||
const stream = [
|
||||
': keepalive\n\n',
|
||||
'event: message\nid: 1\ndata: {"type":"start-step"}\n\n',
|
||||
': keepalive\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
].join('');
|
||||
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'start-step' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-005: skips chunk types this client does not know, and torn JSON', async () => {
|
||||
const stream = [
|
||||
'data: {"type":"reasoning-delta","delta":"hm"}\n\n',
|
||||
'data: {"type":"text-delta","id":"t"\n\n',
|
||||
'data: {"type":"text-delta","id":"t","delta":"ok"}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
].join('');
|
||||
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'ok' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-006: reads an unknown error code as generic', async () => {
|
||||
const stream = 'data: {"type":"error","errorText":"quota_exploded"}\n\ndata: [DONE]\n\n';
|
||||
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'error', errorText: 'generic' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-007: joins the lines of a multi-line data field', async () => {
|
||||
const stream = 'data: {"type":"text-delta",\ndata: "id":"t","delta":"x"}\n\ndata: [DONE]\n\n';
|
||||
expect(await collect(bodyOf([stream]).body)).toEqual([{ type: 'text-delta', id: 't', delta: 'x' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-008: stops at [DONE] and lets go of the connection', async () => {
|
||||
const { body, cancel } = bodyOf([
|
||||
'data: {"type":"start-step"}\n\ndata: [DONE]\n\ndata: {"type":"finish-step"}\n\n',
|
||||
]);
|
||||
expect(await collect(body)).toEqual([{ type: 'start-step' }]);
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-009: a stream that closes before [DONE] broke off, after the chunks it finished', async () => {
|
||||
const { body, cancel } = bodyOf(['data: {"type":"start-step"}\n\ndata: {"type":"finish-st']);
|
||||
const seen: UiStreamChunk[] = [];
|
||||
const read = async () => {
|
||||
for await (const chunk of parseUiMessageStream(body)) seen.push(chunk);
|
||||
};
|
||||
await expect(read()).rejects.toThrow('broke off');
|
||||
expect(seen).toEqual([{ type: 'start-step' }]);
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-011: a stream that stays silent past the stall limit fails and lets go', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const cancel = vi.fn(async () => {});
|
||||
const body = {
|
||||
getReader: () => ({ read: () => new Promise(() => {}), cancel }),
|
||||
} as unknown as ReadableStream<Uint8Array>;
|
||||
const failed = expect(collect(body)).rejects.toThrow('stalled');
|
||||
await vi.advanceTimersByTimeAsync(STALL_MS);
|
||||
await failed;
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-AI-STREAM-010: refuses an event past the size limit, and a consumer leaving early cancels the read', async () => {
|
||||
const huge = bodyOf([`data: ${'x'.repeat(MAX_EVENT_CHARS + 1)}`]);
|
||||
await expect(collect(huge.body)).rejects.toThrow('too large');
|
||||
expect(huge.cancel).toHaveBeenCalledTimes(1);
|
||||
|
||||
const early = bodyOf([FIXTURE]);
|
||||
for await (const chunk of parseUiMessageStream(early.body)) {
|
||||
expect(chunk.type).toBe('start');
|
||||
break;
|
||||
}
|
||||
expect(early.cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { UI_STREAM_DONE, uiStreamChunkSchema, type UiStreamChunk } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Reads the assistant's server-sent events into typed chunks.
|
||||
*
|
||||
* The wire format is the `@trek/shared` stream contract: one `data: <json>`
|
||||
* event per chunk, closed by `data: [DONE]`. Our own parser rather than the
|
||||
* `assistant-stream` one, because that one drops `tool-approval-request`, the
|
||||
* chunk a held change hangs on, and it would pull the chat runtime into every
|
||||
* chunk that only needs the data layer.
|
||||
*
|
||||
* What it tolerates, because proxies and the network do these things: events
|
||||
* split across reads at any byte, CRLF line ends, `:` keep-alive comments,
|
||||
* and fields other than `data`. A chunk that does not match the contract,
|
||||
* a type this client does not know among them, is skipped, so the server can
|
||||
* add chunk types without breaking an older tab. An event longer than
|
||||
* MAX_EVENT_CHARS ends the stream with an error instead of growing the buffer
|
||||
* without bound.
|
||||
*
|
||||
* The server always closes with `[DONE]` and sends a keep-alive every 15
|
||||
* seconds, so a stream that ends without the marker, or stays silent for
|
||||
* STALL_MS, lost its server (a restart, a dropped proxy) and fails instead of
|
||||
* leaving the chat waiting for an answer that will not come.
|
||||
*/
|
||||
|
||||
export const MAX_EVENT_CHARS = 1024 * 1024;
|
||||
export const STALL_MS = 45_000;
|
||||
|
||||
/** The next read, or a failure once nothing came for STALL_MS. */
|
||||
async function readOrStall(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stalled = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('Assistant stream stalled')), STALL_MS);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([reader.read(), stalled]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function* parseUiMessageStream(body: ReadableStream<Uint8Array>): AsyncGenerator<UiStreamChunk> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let finished = false;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await readOrStall(reader);
|
||||
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
||||
// A CR at the very end may be the first half of a CRLF; it waits for the next read.
|
||||
const carry = !done && buffer.endsWith('\r') ? '\r' : '';
|
||||
if (carry) buffer = buffer.slice(0, -1);
|
||||
buffer = buffer.replace(/\r\n?/g, '\n');
|
||||
|
||||
let boundary = buffer.indexOf('\n\n');
|
||||
while (boundary >= 0) {
|
||||
const data = dataOf(buffer.slice(0, boundary));
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
if (data === UI_STREAM_DONE) {
|
||||
finished = true;
|
||||
return;
|
||||
}
|
||||
const chunk = data === null ? null : chunkOf(data);
|
||||
if (chunk) yield chunk;
|
||||
boundary = buffer.indexOf('\n\n');
|
||||
}
|
||||
buffer += carry;
|
||||
|
||||
if (buffer.length > MAX_EVENT_CHARS) throw new Error('Assistant stream event too large');
|
||||
// The connection closed before [DONE]: the server went away mid-answer.
|
||||
if (done) {
|
||||
finished = true;
|
||||
throw new Error('Assistant stream broke off');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Leaving early (the consumer stopped, or an error) releases the connection.
|
||||
if (!finished) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// The stream already failed; the error that brought us here is the one that counts.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The joined `data` lines of one event, or null for an event without any (a comment). */
|
||||
function dataOf(event: string): string | null {
|
||||
const lines: string[] = [];
|
||||
for (const line of event.split('\n')) {
|
||||
if (line === 'data' || line.startsWith('data:')) {
|
||||
const value = line.slice(5);
|
||||
lines.push(value.startsWith(' ') ? value.slice(1) : value);
|
||||
}
|
||||
}
|
||||
return lines.length ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
function chunkOf(data: string): UiStreamChunk | null {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(data);
|
||||
} catch {
|
||||
// A torn or foreign event; the next one is still worth reading.
|
||||
return null;
|
||||
}
|
||||
const parsed = uiStreamChunkSchema.safeParse(json);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AdminFeatureToggleResponse,
|
||||
TourSourceCheckResponse,
|
||||
TourSourceHintResponse,
|
||||
WandererConnection,
|
||||
WandererImportPreview,
|
||||
WandererImportRequest,
|
||||
@@ -11,6 +12,7 @@ import type {
|
||||
WandererSettingsRequest,
|
||||
WandererTestRequest,
|
||||
WandererTestResponse,
|
||||
WandererWatch,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
@@ -31,6 +33,10 @@ 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),
|
||||
@@ -43,6 +49,9 @@ 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. */
|
||||
|
||||
@@ -19,6 +19,8 @@ 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',
|
||||
@@ -70,6 +72,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'vacay:share-removed',
|
||||
'vacay:shared-update',
|
||||
// Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener)
|
||||
// reads the open journey again, and leaves it once journey:deleted says it is gone.
|
||||
'journey:updated',
|
||||
'journey:deleted',
|
||||
'journey:trip:synced',
|
||||
'journey:entry:created',
|
||||
'journey:entry:updated',
|
||||
@@ -79,6 +84,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// Capture times landed after a photo import (#1587); the same listener reloads
|
||||
// the journey so the gallery re-sorts.
|
||||
'journey:photos:updated',
|
||||
// A day's recorded route was taken over or removed (JRN-01):
|
||||
// components/Journey/useJourneyRecordings reads the kept days again.
|
||||
'journey:recording:updated',
|
||||
// Studio book — components/Studio/useBookStore (its own listener: a client
|
||||
// with nothing outstanding takes the new version, one with unsaved edits
|
||||
// deliberately does not and conflicts on its next save instead)
|
||||
@@ -101,6 +109,15 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'import:progress',
|
||||
'import:done',
|
||||
'import:error',
|
||||
// Stays live in page-local planner state, not in the trip store. A stay
|
||||
// written elsewhere (the assistant, a fellow traveller, a plugin, a booking
|
||||
// import) goes remoteEventHandler (STAY_REFRESH_EVENTS) -> accommodations:refresh
|
||||
// -> components/Planner/useStaysRefresh, which reloads them once per burst in
|
||||
// pages/tripPlanner/useTripPlanner (sidebar, map, the phone's day sheet) and
|
||||
// components/Planner/useDayDetail (the desktop day panel).
|
||||
'accommodation:created',
|
||||
'accommodation:updated',
|
||||
'accommodation:deleted',
|
||||
] as const satisfies readonly TrekWsEventName[]
|
||||
|
||||
/**
|
||||
@@ -124,12 +141,23 @@ export const IGNORED_WS_EVENTS = [
|
||||
'budget:settlement-created',
|
||||
'budget:settlement-updated',
|
||||
'budget:settlement-deleted',
|
||||
// Accommodations live in page-local planner state; the client refetches
|
||||
// them off trip:updated date changes, never off these events.
|
||||
'accommodation:created',
|
||||
'accommodation:updated',
|
||||
'accommodation:deleted',
|
||||
'trip:deleted',
|
||||
'member:added',
|
||||
'member:removed',
|
||||
] as const satisfies readonly TrekWsEventName[]
|
||||
|
||||
/**
|
||||
* Events registered ahead of the write that sends them: nothing on the server
|
||||
* sends these yet, so there is nothing for the client to act on. They are not
|
||||
* a decision to ignore. The change that adds a sender moves its event to a
|
||||
* handler, and the list only shrinks.
|
||||
*/
|
||||
export const AWAITING_SENDER_WS_EVENTS = [
|
||||
'member:updated',
|
||||
'todo:reordered',
|
||||
'trips:changed',
|
||||
'settings:updated',
|
||||
'tags:changed',
|
||||
'notification:read-all',
|
||||
'atlas:changed',
|
||||
] as const satisfies readonly TrekWsEventName[]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -24,23 +24,20 @@ function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
|
||||
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
|
||||
}
|
||||
|
||||
function llmAddon(config: Record<string, unknown> = {}) {
|
||||
function llmAddon(config: Record<string, unknown> = {}, enabled = true) {
|
||||
return buildAddon({
|
||||
id: 'llm_parsing',
|
||||
name: 'AI Parsing',
|
||||
description: 'Extract bookings from files',
|
||||
icon: 'Sparkles',
|
||||
type: 'integration',
|
||||
enabled: true,
|
||||
enabled,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
|
||||
});
|
||||
function aiAddon(id: string, name: string, enabled: boolean) {
|
||||
return buildAddon({ id, name, icon: id === 'mcp' ? 'Terminal' : 'Sparkles', type: 'integration', enabled });
|
||||
}
|
||||
|
||||
/** Every switch is labelled with the thing it controls, top-level tile or shelf
|
||||
@@ -78,7 +75,11 @@ beforeEach(() => {
|
||||
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
|
||||
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false })),
|
||||
// The Tours switches load beside the list; unanswered, each would toast the same error as a failed list.
|
||||
http.get('/api/admin/tours-hiking-map', () => HttpResponse.json({ enabled: false })),
|
||||
http.get('/api/admin/tours-cycle-map', () => HttpResponse.json({ enabled: false })),
|
||||
http.get('/api/admin/tours-ski-routing', () => HttpResponse.json({ enabled: false }))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -572,286 +573,55 @@ describe('AddonManager', () => {
|
||||
expect(links.getAttribute('title')).not.toBe('No shared links yet');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
|
||||
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('AI Parsing');
|
||||
// The catalog key now exists, so the tile shows the translation rather than falling
|
||||
// back to the English description the server sent.
|
||||
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
const label = await screen.findByText('Provider');
|
||||
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
|
||||
const tile = label.closest('article');
|
||||
expect(tile).not.toBeNull();
|
||||
expect(tile!.textContent).toContain('AI Parsing');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
await screen.findByRole('button', { name: 'llama3:8b' });
|
||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
|
||||
|
||||
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
it('FE-ADMIN-ADDON-020: the AI addons head the integration column as one tile with a switch each', async () => {
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? HttpResponse.json({ error: 'down' }, { status: 500 })
|
||||
: HttpResponse.json({ models: [] });
|
||||
}),
|
||||
addonsRoute([
|
||||
aiAddon('mcp', 'MCP', false),
|
||||
llmAddon({}, false),
|
||||
aiAddon('ai_assistant', 'AI Assistant', false),
|
||||
buildAddon({ id: 'airtrail', name: 'AirTrail', type: 'integration', icon: 'Plane' }),
|
||||
]),
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
// The server's reason is shown, not axios' status line (#2649)
|
||||
await screen.findByText('down');
|
||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
expect(calls).toBe(2);
|
||||
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
|
||||
const switches = within(tile).getAllByRole('button').map((b) => b.getAttribute('aria-label'));
|
||||
// In the tile's order, whatever order the server sent them in.
|
||||
expect(switches).toEqual(['AI Assistant', 'AI Parsing', 'MCP']);
|
||||
expect(within(tile).getByText('0/3')).toBeInTheDocument();
|
||||
// Their own icons, keyed by id: the icon column of an install still says Sparkles or Terminal.
|
||||
expect(tile.querySelector('svg.lucide-messages-square')).toBeInTheDocument();
|
||||
expect(tile.querySelector('svg.lucide-scan-text')).toBeInTheDocument();
|
||||
expect(tile.querySelector('svg.lucide-cable')).toBeInTheDocument();
|
||||
// Nothing to set up while neither the assistant nor parsing is on.
|
||||
expect(within(tile).queryByRole('button', { name: 'Set up AI' })).toBeNull();
|
||||
// AirTrail is still its own tile in the same column.
|
||||
expect(screen.getByText('AirTrail').closest('article')).not.toBe(tile);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
|
||||
it('FE-ADMIN-ADDON-021: with parsing on, the tile offers the settings dialog, which closes again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
|
||||
server.use(
|
||||
addonsRoute([llmAddon({}), aiAddon('ai_assistant', 'AI Assistant', false), aiAddon('mcp', 'MCP', true)]),
|
||||
http.get('/api/admin/ai/settings', () =>
|
||||
HttpResponse.json({ policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } }),
|
||||
),
|
||||
http.get('/api/admin/ai/connection', () => HttpResponse.json({ connection: null, prefill: null })),
|
||||
http.get('/api/ai/status', () => HttpResponse.json({ error: 'not needed' }, { status: 404 })),
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
|
||||
|
||||
// A hand-typed base URL is used for the next lookup on blur
|
||||
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
|
||||
await user.tab();
|
||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
|
||||
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
const tile = (await screen.findByText('AI')).closest('article') as HTMLElement;
|
||||
expect(within(tile).getByText('2/3')).toBeInTheDocument();
|
||||
await user.click(within(tile).getByRole('button', { name: 'Set up AI' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole('button', { name: 'AI Assistant' })).toBeNull();
|
||||
const closes = within(dialog).getAllByRole('button', { name: 'Close' });
|
||||
await user.click(closes[closes.length - 1]);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
|
||||
const user = userEvent.setup();
|
||||
let pulled: unknown = null;
|
||||
let modelCalls = 0;
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('Pulling…');
|
||||
expect(screen.getByText('starting…')).toBeInTheDocument();
|
||||
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
await screen.findByText('no disk space');
|
||||
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Failed to save');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
|
||||
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
// The model is trimmed before it is stored, the key is sent verbatim
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
|
||||
modelsRoute([]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
|
||||
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-038: whether the model reads images is auto, on or off, kept as stored and saved as chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'off' })]),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
// Stored off stays off; a cloud provider is told what Automatic means for it.
|
||||
await user.click(await screen.findByRole('button', { name: 'No' }));
|
||||
expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Saved');
|
||||
expect(bodies[0]).toMatchObject({ config: { provider: 'openai', vision: 'on' } });
|
||||
expect(bodies[0]).not.toHaveProperty('config.multimodal');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([]));
|
||||
render(<AddonManager />);
|
||||
expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
)),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
await screen.findByText('No models installed yet — pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
|
||||
await screen.findByText('manifest not found');
|
||||
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
||||
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Provider');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
|
||||
expect(urls).toHaveLength(0);
|
||||
});
|
||||
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
@@ -897,6 +667,10 @@ describe('AddonManager', () => {
|
||||
</>
|
||||
);
|
||||
|
||||
// Folded like Collab's and Documents' options until the bar is opened.
|
||||
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
|
||||
await unfoldShelves();
|
||||
const toggle = await screen.findByRole('button', { name: 'wanderer' });
|
||||
expect(isOn(toggle)).toBe(false);
|
||||
await userEvent.click(toggle);
|
||||
@@ -917,6 +691,7 @@ describe('AddonManager', () => {
|
||||
</>
|
||||
);
|
||||
|
||||
await unfoldShelves();
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
|
||||
|
||||
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useId, type ComponentType } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Bookmark, Users, Loader2, Mountain, CarFront } from 'lucide-react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
@@ -12,11 +10,10 @@ import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
|
||||
import { SettingsCard, StatusPill } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel } from './addons/addonModel'
|
||||
import { useAddonManager } from './addons/useAddonManager'
|
||||
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
|
||||
import AiAddonTile from './addons/AiAddonTile'
|
||||
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
|
||||
import WandererMark from '../Tours/wanderer/WandererMark'
|
||||
|
||||
@@ -74,9 +71,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const { t } = useTranslation()
|
||||
const dark = useIsDark()
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
|
||||
if (loading) {
|
||||
@@ -153,21 +151,29 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
})
|
||||
}
|
||||
// An array, not a fragment: the tile counts its rows to fold them behind the
|
||||
// "N options" bar the way Collab's and Documents' are.
|
||||
if (addon.id === 'tours' && addon.enabled) {
|
||||
return (
|
||||
return [
|
||||
<AddonSubRow
|
||||
key="wanderer"
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// 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} />
|
||||
/>,
|
||||
...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()}
|
||||
/>
|
||||
)),
|
||||
]
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -196,9 +202,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
{ key: 'integration', addons: integrationAddons },
|
||||
].filter(g => g.addons.length > 0)
|
||||
].filter(g => g.addons.length > 0 || (g.key === 'integration' && aiAddons.length > 0))
|
||||
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + aiAddons.length
|
||||
|
||||
const subtitle = (
|
||||
<span className="inline-flex flex-wrap items-center gap-1">
|
||||
@@ -229,8 +235,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
|
||||
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons} t={t} />
|
||||
<div className="divide-y divide-edge-faint">
|
||||
{/* The AI addons share a connection and a settings dialog, so they head the column as one tile. */}
|
||||
{g.key === 'integration' && aiAddons.length > 0 && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
|
||||
{g.addons.map(tile)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
@@ -269,140 +279,3 @@ function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config. When set, applies to the whole instance and
|
||||
* overrides per-user config (see server llmConfig.ts). The API key is masked on
|
||||
* read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider, it also lists installed Ollama models and can pull NuExtract models.
|
||||
*/
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
|
||||
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
|
||||
} = useLlmParsingConfig(addon)
|
||||
const fieldId = useId()
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
||||
]
|
||||
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
|
||||
|
||||
/* Lives in the tile's shelf like the collab toggles, so it is one column of
|
||||
eyebrow fields: the band this used to be had a whole page width. */
|
||||
return (
|
||||
<li className={`flex flex-col gap-3 p-3 ${saving ? 'opacity-60' : ''}`}>
|
||||
<SettingsHint>
|
||||
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
|
||||
</SettingsHint>
|
||||
|
||||
<EditorField label="Provider">
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</EditorField>
|
||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label="API key"
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
|
||||
>
|
||||
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</EditorField>
|
||||
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
|
||||
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</EditorField>
|
||||
|
||||
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
|
||||
<Segmented<LlmVision>
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
value={vision}
|
||||
onChange={v => setVision(asLlmVision(v))}
|
||||
options={visionOptions}
|
||||
fill
|
||||
/>
|
||||
</EditorField>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels}
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||
style={fs(11.5, 'body')}>
|
||||
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
aria-pressed={model === name}
|
||||
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
|
||||
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPulling && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ChevronRight, Settings2, Sparkles } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { StatusPill } from '../../Settings/settingsKit';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import AddonSubRow from '../AddonSubRow';
|
||||
import { type Addon, getAddonLabel } from './addonModel';
|
||||
import { AI_ADDON_ICONS, type AiAddonId } from './aiAddons';
|
||||
import AiSettingsDialog from './AiAdminConfig';
|
||||
import { useAiAddonTile } from './useAiAddonTile';
|
||||
|
||||
/**
|
||||
* The AI addons as one tile at the head of the integration column: the
|
||||
* assistant, AI parsing and MCP as switches in its shelf, and one button to
|
||||
* the dialog that holds their settings, so a page with all three on stays as
|
||||
* short as one with none.
|
||||
*/
|
||||
export default function AiAddonTile({
|
||||
addons,
|
||||
onToggle,
|
||||
}: {
|
||||
addons: Addon[];
|
||||
onToggle: (addon: Addon) => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const tile = useAiAddonTile(addons);
|
||||
const anyOn = tile.enabledCount > 0;
|
||||
return (
|
||||
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-[10px] transition-colors ${
|
||||
anyOn
|
||||
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
|
||||
: 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-px">
|
||||
<h4 className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{t('admin.addons.ai.name')}
|
||||
</h4>
|
||||
<p
|
||||
className={`m-0 mt-0.5 line-clamp-2 leading-snug ${anyOn ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('admin.addons.ai.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-none pt-1">
|
||||
<StatusPill tone={anyOn ? 'success' : 'neutral'}>
|
||||
{tile.enabledCount}/{addons.length}
|
||||
</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint p-0">
|
||||
{addons.map((addon) => {
|
||||
const Icon = AI_ADDON_ICONS[addon.id as AiAddonId];
|
||||
const label = getAddonLabel(t, addon);
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={addon.id}
|
||||
icon={<Icon size={14} />}
|
||||
title={label.name}
|
||||
description={label.description}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => onToggle(addon)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{tile.canSetUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={tile.openDialog}
|
||||
className="flex min-h-[44px] w-full items-center gap-2.5 border-t border-edge-faint px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
|
||||
<Settings2 size={14} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.addons.ai.setup')}
|
||||
</span>
|
||||
<ChevronRight size={15} strokeWidth={2.2} className="flex-none text-content-faint rtl:rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tile.dialogOpen && <AiSettingsDialog addons={addons} onClose={tile.closeDialog} />}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// FE-COMP-AI-ADMIN-001 to FE-COMP-AI-ADMIN-020: the AI settings dialog behind the AI tile.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { ToastContainer } from '../../shared/Toast';
|
||||
import type { Addon } from './addonModel';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
adminSettings: vi.fn(),
|
||||
saveAdminSettings: vi.fn(),
|
||||
adminConnection: vi.fn(),
|
||||
saveAdminConnection: vi.fn(),
|
||||
removeAdminConnection: vi.fn(),
|
||||
adminModels: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
|
||||
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'openai',
|
||||
model: 'gpt-5.5',
|
||||
hint: '…9f3c',
|
||||
status: 'ok',
|
||||
host: 'api.openai.com',
|
||||
baseUrl: null,
|
||||
},
|
||||
prefill: null,
|
||||
};
|
||||
|
||||
function addon(id: string, enabled: boolean, config?: Record<string, unknown>): Addon {
|
||||
return { id, name: id, description: '', icon: 'Sparkles', type: 'integration', enabled, config } as Addon;
|
||||
}
|
||||
|
||||
const ASSISTANT = addon('ai_assistant', true);
|
||||
const parsing = (config: Record<string, unknown> = {}) => addon('llm_parsing', true, config);
|
||||
|
||||
function renderDialog(addons: Addon[] = [ASSISTANT]) {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<AiSettingsDialog addons={addons} onClose={onClose} />
|
||||
</>
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
/** The dialog opened on AI parsing's tab. */
|
||||
async function renderParsing(config: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup();
|
||||
renderDialog([ASSISTANT, parsing(config)]);
|
||||
await user.click(await screen.findByRole('button', { name: 'AI Parsing' }));
|
||||
return user;
|
||||
}
|
||||
|
||||
function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
return http.get('/api/admin/llm/local/models', ({ request }) => {
|
||||
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
|
||||
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
|
||||
});
|
||||
}
|
||||
|
||||
function saveRoute(bodies: unknown[], fail = false) {
|
||||
return http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return fail ? HttpResponse.error() : HttpResponse.json({ success: true });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
repo.adminConnection.mockResolvedValue({ connection: null, prefill: null });
|
||||
repo.removeAdminConnection.mockResolvedValue(undefined);
|
||||
repo.adminModels.mockResolvedValue([{ id: 'gpt-5.5', label: 'GPT-5.5', supportsTools: true }]);
|
||||
});
|
||||
|
||||
describe('the connection and the assistant', () => {
|
||||
it('FE-COMP-AI-ADMIN-001: shows who pays and the setup for everyone, the limits on the assistant tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
|
||||
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
|
||||
// The admin may share their own ChatGPT plan with everyone, so its tile is offered too.
|
||||
expect(screen.getByRole('button', { name: /Sign in with ChatGPT/ })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
||||
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
|
||||
expect(screen.getByText('Keep chats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('radio', { name: 'Everyone uses their own account' }));
|
||||
await waitFor(() => expect(screen.queryByText('No connection yet.')).not.toBeInTheDocument());
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
|
||||
expect(screen.getByText('Everyone connects their own provider. Nothing is charged to you.')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
|
||||
expect(screen.queryByText('Messages per person per day')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-003: a live connection shows its provider and opens the setup only to change it', async () => {
|
||||
const user = userEvent.setup();
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
renderDialog();
|
||||
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
|
||||
expect(screen.getByText('…9f3c')).toBeInTheDocument();
|
||||
expect(screen.queryByText('What would you like to chat with?')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Change' }));
|
||||
expect(screen.getByText('What would you like to chat with?')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-004: removing the connection asks first', async () => {
|
||||
const user = userEvent.setup();
|
||||
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue({ connection: null, prefill: null });
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('button', { name: 'Remove connection' }));
|
||||
expect(screen.getByText('Disconnect OpenAI? Your chats stay.')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button', { name: 'Remove connection' });
|
||||
await user.click(buttons[buttons.length - 1]);
|
||||
expect(await screen.findByText('No connection yet.')).toBeInTheDocument();
|
||||
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-005: a tab shows only while its addon is on, and Close closes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = renderDialog([addon('ai_assistant', false), parsing()]);
|
||||
expect(await screen.findByRole('button', { name: 'AI Parsing' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'AI Assistant' })).not.toBeInTheDocument();
|
||||
const closes = screen.getAllByRole('button', { name: 'Close' });
|
||||
await user.click(closes[closes.length - 1]);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI parsing', () => {
|
||||
it('FE-COMP-AI-ADMIN-010: without its own connection parsing reads with the shared one, and can be given its own', async () => {
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
server.use(modelsRoute([]));
|
||||
const user = await renderParsing({ vision: 'auto' });
|
||||
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.5')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Use a different one' }));
|
||||
expect(screen.getByLabelText('Model')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.queryByLabelText('Model')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-011: with no connection anywhere it says so and leads to the connection tab', async () => {
|
||||
const user = await renderParsing();
|
||||
expect(
|
||||
await screen.findByText('No connection yet. Set one up for everyone, or give AI parsing its own.')
|
||||
).toBeInTheDocument();
|
||||
// The link in the hint, not the tab of the same name above it.
|
||||
const link = screen.getAllByRole('button', { name: 'Connection' }).find((b) => !b.hasAttribute('aria-pressed'));
|
||||
await user.click(link as HTMLElement);
|
||||
expect(await screen.findByRole('radio', { name: 'I pay, own allowed' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-012: an own connection is shown as such and goes back to the shared one, keeping the vision setting', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'anthropic', model: 'claude-x', apiKey: '••••••••', vision: 'on' });
|
||||
expect(await screen.findByText('Own connection', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByText('claude-x')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Use the shared connection' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({ config: { vision: 'on' } });
|
||||
expect(await screen.findByText('Shared')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-013: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||
const user = await renderParsing({ provider: 'local', model: 'old' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(await screen.findByRole('button', { name: 'llama3:8b' }));
|
||||
expect(urls[0]).toBe('http://localhost:11434/v1');
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('llama3:8b');
|
||||
// qwen3.5:4b is installed already, so the recommended row offers Use instead of Pull.
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("FE-COMP-AI-ADMIN-014: an unreachable Ollama shows the server's reason and Refresh retries", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
|
||||
})
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('down');
|
||||
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-015: switching providers swaps the address placeholder and the Ollama block, and drops a stale address', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'local', model: 'm', baseUrl: 'http://ollama.lan:11434/v1' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
||||
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toHaveValue('');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('radio', { name: 'Anthropic' }));
|
||||
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
await user.clear(screen.getByLabelText('Model'));
|
||||
await user.type(screen.getByLabelText('Model'), ' claude-haiku ');
|
||||
await user.type(screen.getByLabelText('API key'), 'sk-ant-live');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
// The stale local address must not ride along to Anthropic, and the model is trimmed.
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'anthropic', model: 'claude-haiku', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-016: a hand-typed address is asked on blur, a cloud provider asks nothing', async () => {
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(modelsRoute([], urls));
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.type(screen.getByLabelText('Base URL'), 'http://ollama.lan:11434/v1');
|
||||
await user.tab();
|
||||
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
|
||||
const before = urls.length;
|
||||
await user.click(screen.getByRole('radio', { name: 'OpenAI' }));
|
||||
await user.type(screen.getByLabelText('Base URL'), 'https://proxy.local/v1');
|
||||
await user.tab();
|
||||
expect(urls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-017: a pull streams progress and then selects the model', async () => {
|
||||
let pulled: unknown = null;
|
||||
let modelCalls = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } }
|
||||
);
|
||||
})
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
expect(await screen.findAllByText('Pulling…')).not.toHaveLength(0);
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('qwen3.5:4b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-018: an error frame or a refused pull is reported and the button comes back', async () => {
|
||||
server.use(
|
||||
modelsRoute([]),
|
||||
http.post(
|
||||
'/api/admin/llm/local/pull',
|
||||
() =>
|
||||
new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', {
|
||||
headers: { 'Content-Type': 'application/x-ndjson' },
|
||||
})
|
||||
)
|
||||
);
|
||||
const user = await renderParsing({ provider: 'local', model: 'm' });
|
||||
await screen.findByText('No models installed yet. Pull one below.');
|
||||
await user.click(screen.getByRole('button', { name: 'Pull' }));
|
||||
await screen.findByText('manifest not found');
|
||||
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-019: saving keeps a masked key and reports both outcomes', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(modelsRoute([]), saveRoute(bodies));
|
||||
const user = await renderParsing({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••' });
|
||||
await screen.findByText('Installed on the server');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', vision: 'auto' },
|
||||
});
|
||||
server.use(saveRoute(bodies, true));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Could not save AI settings');
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-020: whether the model reads images saves at once, with the connection as saved', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(saveRoute(bodies));
|
||||
const user = await renderParsing({
|
||||
provider: 'openai',
|
||||
model: 'gpt-4.1-mini',
|
||||
baseUrl: '',
|
||||
apiKey: 'sk',
|
||||
vision: 'off',
|
||||
});
|
||||
expect(await screen.findByRole('button', { name: 'No' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Yes' }));
|
||||
await screen.findByText('AI settings saved');
|
||||
expect(bodies[0]).toEqual({
|
||||
config: { provider: 'openai', model: 'gpt-4.1-mini', baseUrl: '', apiKey: 'sk', vision: 'on' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { Check, Loader2, Sparkles } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import CustomSelect from '../../shared/CustomSelect';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../../shared/DialogShell';
|
||||
import { NumericInput } from '../../shared/NumericInput';
|
||||
import { EditorField, INPUT, Segmented } from '../../shared/dialogParts';
|
||||
import ParsingSettings from './ParsingSettings';
|
||||
import type { Addon } from './addonModel';
|
||||
import { POLICY_CHOICES, useAiAdminConfig } from './useAiAdminConfig';
|
||||
|
||||
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
|
||||
|
||||
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint';
|
||||
|
||||
/** Who pays, as three rows to pick one from: the labels are too long for a segmented track in a shelf. */
|
||||
function PolicyChoice({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={EYEBROW} style={fs(9.5)}>
|
||||
{t('assistant.admin.policy')}
|
||||
</span>
|
||||
<div role="radiogroup" aria-label={t('assistant.admin.policy')} className="flex flex-col gap-1.5">
|
||||
{POLICY_CHOICES.map((choice) => {
|
||||
const on = A.policy === choice.value;
|
||||
return (
|
||||
<button
|
||||
key={choice.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
onClick={() => A.setPolicy(choice.value)}
|
||||
className={`flex items-center gap-2 rounded-[10px] border px-3 py-2 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 font-medium ${on ? 'text-content' : 'text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(choice.labelKey)}
|
||||
</span>
|
||||
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-content" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{A.policyHintKey && <SettingsHint>{t(A.policyHintKey)}</SettingsHint>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The connection everyone runs on, once set up: who answers, its model, and replacing or removing it. */
|
||||
function LiveConnection({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const { live, provider, models, busy, setModel, replace, askRemove } = A.instance;
|
||||
if (!live) return null;
|
||||
const status = CONNECTION_STATUS[live.status];
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-3 rounded-[12px] border border-edge-faint bg-surface-card p-3 ${busy ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ProviderMark kind={live.kind} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{provider}
|
||||
</p>
|
||||
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
|
||||
{live.hint ?? live.host}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill tone={status.tone}>{t(status.labelKey)}</StatusPill>
|
||||
</div>
|
||||
{live.status === 'ok' && (
|
||||
<EditorField label={t('assistant.connect.model')} hint={t('assistant.connect.modelHint')}>
|
||||
<CustomSelect
|
||||
ariaLabel={t('assistant.connect.model')}
|
||||
value={live.model}
|
||||
onChange={(v) => setModel(String(v))}
|
||||
options={models}
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
searchable={models.length > 8}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
|
||||
<button type="button" onClick={replace} disabled={busy} className={SETTINGS_BUTTON}>
|
||||
{t('common.change')}
|
||||
</button>
|
||||
<button type="button" onClick={askRemove} disabled={busy} className={SETTINGS_BUTTON_DANGER}>
|
||||
{t('assistant.admin.connectionRemove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceConnection({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const { live, connection, replacing, cancelReplace, onConnected } = A.instance;
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<span className={EYEBROW} style={fs(9.5)}>
|
||||
{t('assistant.admin.connection')}
|
||||
</span>
|
||||
{!replacing && <LiveConnection A={A} />}
|
||||
{!live && <SettingsHint>{t('assistant.admin.connectionNone')}</SettingsHint>}
|
||||
{A.showPanel && (
|
||||
<AiConnectPanel scope="instance" onConnected={onConnected} prefill={connection?.prefill ?? null} />
|
||||
)}
|
||||
{replacing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelReplace}
|
||||
className={`${SETTINGS_BUTTON} self-start`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
|
||||
function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{A.instancePays && (
|
||||
<EditorField
|
||||
label={t('assistant.admin.dailyLimit')}
|
||||
htmlFor={`${fieldId}-daily`}
|
||||
hint={t('assistant.admin.dailyLimitHint')}
|
||||
>
|
||||
<NumericInput
|
||||
id={`${fieldId}-daily`}
|
||||
mode="integer"
|
||||
className={`${INPUT} tabular-nums`}
|
||||
value={A.dailyDraft}
|
||||
onValueChange={A.setDailyDraft}
|
||||
onBlur={A.commitDailyLimit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
<EditorField label={t('assistant.admin.retention')}>
|
||||
<CustomSelect
|
||||
ariaLabel={t('assistant.admin.retention')}
|
||||
value={A.retention}
|
||||
onChange={(v) => A.setRetention(String(v))}
|
||||
options={A.retentionOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AiTab = 'connection' | 'assistant' | 'parsing';
|
||||
|
||||
/**
|
||||
* The AI settings behind the AI tile's button, in one dialog so the addon page
|
||||
* stays short: the connection the assistant and AI parsing share (and who pays
|
||||
* for it), the assistant's limits, and how bookings are read. A tab shows only
|
||||
* while its addon is on. Each change saves at once.
|
||||
*/
|
||||
export default function AiSettingsDialog({
|
||||
addons,
|
||||
onClose,
|
||||
}: {
|
||||
addons: Addon[];
|
||||
onClose: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const A = useAiAdminConfig();
|
||||
const assistantOn = addons.some((a) => a.id === 'ai_assistant' && a.enabled);
|
||||
const parsing = addons.find((a) => a.id === 'llm_parsing' && a.enabled) ?? null;
|
||||
const tabs: { value: AiTab; label: string }[] = [
|
||||
{ value: 'connection', label: t('admin.addons.ai.tab.connection') },
|
||||
...(assistantOn ? [{ value: 'assistant' as const, label: t('admin.addons.catalog.ai_assistant.name') }] : []),
|
||||
...(parsing ? [{ value: 'parsing' as const, label: t('admin.addons.catalog.llm_parsing.name') }] : []),
|
||||
];
|
||||
const [tab, setTab] = useState<AiTab>('connection');
|
||||
const shown = tabs.some((x) => x.value === tab) ? tab : 'connection';
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="detail"
|
||||
align="top"
|
||||
blocked={A.instance.confirmingRemove}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<Sparkles size={20} strokeWidth={2} />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
eyebrow={t('admin.addons.ai.name')}
|
||||
title={t('admin.addons.ai.setup')}
|
||||
sub={t('admin.addons.ai.dialogSub')}
|
||||
subWraps
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
{tabs.length > 1 && (
|
||||
<Segmented<AiTab> label={t('admin.addons.ai.setup')} value={shown} onChange={setTab} options={tabs} fill />
|
||||
)}
|
||||
{!A.loaded ? (
|
||||
<div className="grid place-items-center py-10">
|
||||
<Loader2 size={20} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{shown === 'connection' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PolicyChoice A={A} />
|
||||
{A.instancePays && <InstanceConnection A={A} />}
|
||||
</div>
|
||||
)}
|
||||
{shown === 'assistant' && <AssistantLimits A={A} />}
|
||||
{shown === 'parsing' && parsing && (
|
||||
<ParsingSettings addon={parsing} A={A} onOpenConnection={() => setTab('connection')} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={A.instance.confirmingRemove}
|
||||
onClose={A.instance.cancelRemove}
|
||||
onConfirm={A.instance.remove}
|
||||
title={t('assistant.admin.connectionRemove')}
|
||||
message={t('assistant.connect.disconnectConfirm', { provider: A.instance.provider })}
|
||||
confirmLabel={t('assistant.admin.connectionRemove')}
|
||||
danger
|
||||
/>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared';
|
||||
import { Check, RefreshCw } from 'lucide-react';
|
||||
import React, { useId } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import { DialogSection, fs } from '../../shared/DialogShell';
|
||||
import { EditorField, INPUT, PANEL, Segmented } from '../../shared/dialogParts';
|
||||
import { type Addon, ANTHROPIC_HINT, MASKED, RECOMMENDED_MODELS } from './addonModel';
|
||||
import type { useAiAdminConfig } from './useAiAdminConfig';
|
||||
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
||||
|
||||
type AiAdmin = ReturnType<typeof useAiAdminConfig>;
|
||||
type Parsing = ReturnType<typeof useLlmParsingConfig>;
|
||||
type MarkKind = Parameters<typeof ProviderMark>[0]['kind'];
|
||||
|
||||
/** Parsing's own providers, with the mark the assistant shows for each. */
|
||||
const OWN_PROVIDERS = [
|
||||
{ value: 'local', labelKey: 'settings.aiParsing.providerLocal', mark: 'custom' },
|
||||
{ value: 'openai', labelKey: 'settings.aiParsing.providerOpenai', mark: 'openai' },
|
||||
{ value: 'anthropic', labelKey: 'settings.aiParsing.providerAnthropic', mark: 'anthropic' },
|
||||
] as const;
|
||||
|
||||
const markOf = (provider: string): MarkKind => OWN_PROVIDERS.find((p) => p.value === provider)?.mark ?? 'custom';
|
||||
|
||||
/** The connection parsing reads with, as one line: a mark, who answers, the model and where it comes from. */
|
||||
function ReadsWith({ mark, title, model, tag }: { mark: MarkKind; title: string; model: string; tag: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
|
||||
<ProviderMark kind={mark} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="m-0 truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{title}
|
||||
</p>
|
||||
<p className="m-0 truncate font-geist text-content-faint" style={fs(11)}>
|
||||
{model}
|
||||
</p>
|
||||
</div>
|
||||
<StatusPill>{tag}</StatusPill>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Which connection parsing reads with now, and the way to the other one. */
|
||||
function ParsingSource({ A, P, onOpenConnection }: { A: AiAdmin; P: Parsing; onOpenConnection: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const live = A.instancePays ? A.instance.live : null;
|
||||
if (P.ownConnection) {
|
||||
const own = OWN_PROVIDERS.find((p) => p.value === P.savedProvider);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<ReadsWith
|
||||
mark={markOf(P.savedProvider)}
|
||||
title={own ? t(own.labelKey) : P.savedProvider}
|
||||
model={P.savedModel}
|
||||
tag={t('admin.addons.ai.parsing.own')}
|
||||
/>
|
||||
<SettingsHint>{t('admin.addons.ai.parsing.ownHint')}</SettingsHint>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.switchToShared()}
|
||||
disabled={P.saving}
|
||||
className={`${SETTINGS_BUTTON} self-start`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.addons.ai.parsing.useShared')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{live ? (
|
||||
<ReadsWith
|
||||
mark={live.kind}
|
||||
title={A.instance.provider}
|
||||
model={live.model}
|
||||
tag={t('admin.addons.ai.parsing.sharedTag')}
|
||||
/>
|
||||
) : (
|
||||
<SettingsHint>
|
||||
{t(A.policy === 'user' ? 'admin.addons.ai.parsing.perUser' : 'admin.addons.ai.parsing.none')}
|
||||
</SettingsHint>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2" style={fs(12.5, 'body')}>
|
||||
{!live && A.policy !== 'user' && (
|
||||
<button type="button" onClick={onOpenConnection} className={SETTINGS_BUTTON_PRIMARY}>
|
||||
{t('admin.addons.ai.tab.connection')}
|
||||
</button>
|
||||
)}
|
||||
{!P.formOpen && (
|
||||
<button type="button" onClick={P.startOwn} className={SETTINGS_BUTTON}>
|
||||
{t('admin.addons.ai.parsing.setupOwn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The models on the local server to pick from, and the recommended ones to pull. */
|
||||
function LocalModels({ P }: { P: Parsing }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={PANEL}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-geist font-bold uppercase tracking-[.08em] text-content-faint"
|
||||
style={fs(9.5)}
|
||||
>
|
||||
{t('admin.addons.ai.parsing.installed')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.loadModels()}
|
||||
disabled={P.loadingModels}
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-card hover:text-content disabled:cursor-default disabled:opacity-60"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<RefreshCw size={11} strokeWidth={2.2} className={P.loadingModels ? 'animate-spin' : undefined} />
|
||||
{t('admin.addons.ai.parsing.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
{P.modelsErr && (
|
||||
<p className="m-0 break-words text-danger" style={fs(11.5)}>
|
||||
{P.modelsErr}
|
||||
</p>
|
||||
)}
|
||||
{!P.modelsErr && P.installed.length === 0 && !P.loadingModels && (
|
||||
<SettingsHint>{t('admin.addons.ai.parsing.noModels')}</SettingsHint>
|
||||
)}
|
||||
{P.installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{P.installed.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => P.setModel(name)}
|
||||
aria-pressed={P.model === name}
|
||||
className={`max-w-full truncate rounded-full px-2.5 py-[3px] font-geist font-medium transition-colors ${P.model === name ? 'bg-accent text-accent-text' : 'border border-edge bg-surface-card text-content-muted hover:text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
|
||||
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
|
||||
{t('admin.addons.ai.parsing.pullTitle')}
|
||||
</span>
|
||||
{RECOMMENDED_MODELS.map((m) => {
|
||||
const here = P.isInstalled(m.id);
|
||||
const chosen = P.model === m.id;
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{m.label}
|
||||
</span>
|
||||
{m.recommended && <StatusPill tone="success">{t('assistant.model.recommended')}</StatusPill>}
|
||||
{here ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => P.setModel(m.id)}
|
||||
disabled={chosen}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{chosen ? <Check size={13} strokeWidth={2.4} /> : null}
|
||||
{t(chosen ? 'admin.addons.ai.parsing.selected' : 'admin.addons.ai.parsing.use')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.pull(m.id)}
|
||||
disabled={!!P.pulling}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t(P.pulling === m.id ? 'admin.addons.ai.parsing.pulling' : 'admin.addons.ai.parsing.pull')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{P.pulling === m.id && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-200"
|
||||
style={{ width: `${P.pullPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{P.pullStatus}
|
||||
{P.pullPct ? ` ${P.pullPct}%` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A connection of parsing's own: the provider as tiles, its address, key and model. */
|
||||
function OwnConnectionForm({ P }: { P: Parsing }) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
return (
|
||||
<div className={`flex flex-col gap-3 ${P.saving ? 'opacity-60' : ''}`}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('settings.aiParsing.provider')}
|
||||
className="grid grid-cols-3 gap-2 max-sm:grid-cols-1"
|
||||
>
|
||||
{OWN_PROVIDERS.map((p) => {
|
||||
const on = P.provider === p.value;
|
||||
return (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
onClick={() => P.setProvider(p.value)}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-[12px] border p-2.5 text-start transition-colors ${on ? 'border-[color:var(--text-primary)] bg-surface-card shadow-sm' : 'border-edge bg-surface-card hover:border-edge-secondary'}`}
|
||||
>
|
||||
<ProviderMark kind={p.mark} size="sm" />
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate font-medium ${on ? 'text-content' : 'text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(p.labelKey)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<EditorField
|
||||
label={t('settings.aiParsing.baseUrl')}
|
||||
htmlFor={`${fieldId}-url`}
|
||||
hint={t('settings.aiParsing.baseUrlHint')}
|
||||
>
|
||||
<input
|
||||
id={`${fieldId}-url`}
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
className={INPUT}
|
||||
value={P.baseUrl}
|
||||
onChange={(e) => P.setBaseUrl(e.target.value)}
|
||||
onBlur={() => void P.loadModels()}
|
||||
placeholder={P.baseUrlPlaceholder}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label={t('settings.aiParsing.apiKey')}
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={P.provider === 'anthropic' ? ANTHROPIC_HINT : t('settings.aiParsing.apiKeyHint')}
|
||||
>
|
||||
<input
|
||||
id={`${fieldId}-key`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className={INPUT}
|
||||
value={P.apiKey}
|
||||
onChange={(e) => P.setApiKey(e.target.value)}
|
||||
placeholder={
|
||||
P.apiKey === MASKED ? MASKED : P.provider === 'local' ? t('admin.addons.ai.parsing.apiKeyOptional') : ''
|
||||
}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField label={t('settings.aiParsing.model')} htmlFor={`${fieldId}-model`}>
|
||||
<input
|
||||
id={`${fieldId}-model`}
|
||||
autoComplete="off"
|
||||
className={`${INPUT} font-geist`}
|
||||
value={P.model}
|
||||
onChange={(e) => P.setModel(e.target.value)}
|
||||
/>
|
||||
</EditorField>
|
||||
{P.provider === 'local' && <LocalModels P={P} />}
|
||||
<div className="flex justify-end gap-2" style={fs(13, 'body')}>
|
||||
{!P.ownConnection && (
|
||||
<button type="button" onClick={P.cancelOwn} disabled={P.saving} className={SETTINGS_BUTTON}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void P.save()}
|
||||
disabled={P.saving || !P.model.trim()}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
{t(P.saving ? 'common.saving' : 'common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI parsing in the AI dialog: which connection reads the bookings (the shared
|
||||
* one, or one of its own an older install kept), a connection of its own when
|
||||
* wanted, and whether the model reads images. Each part saves on its own.
|
||||
*/
|
||||
export default function ParsingSettings({
|
||||
addon,
|
||||
A,
|
||||
onOpenConnection,
|
||||
}: {
|
||||
addon: Addon;
|
||||
A: AiAdmin;
|
||||
onOpenConnection: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const P = useLlmParsingConfig(addon);
|
||||
const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) }));
|
||||
const local =
|
||||
(P.ownConnection ? P.savedProvider : A.instance.live?.kind === 'custom' ? 'local' : 'cloud') === 'local';
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<DialogSection label={t('admin.addons.ai.parsing.readsWith')}>
|
||||
<ParsingSource A={A} P={P} onOpenConnection={onOpenConnection} />
|
||||
</DialogSection>
|
||||
{P.formOpen && (
|
||||
<DialogSection label={t('admin.addons.ai.parsing.own')}>
|
||||
<OwnConnectionForm P={P} />
|
||||
</DialogSection>
|
||||
)}
|
||||
<DialogSection label={t('settings.aiParsing.multimodal')}>
|
||||
<Segmented<LlmVision>
|
||||
label={t('settings.aiParsing.multimodal')}
|
||||
value={P.vision}
|
||||
onChange={(v) => P.setVision(asLlmVision(v))}
|
||||
options={visionOptions}
|
||||
fill
|
||||
/>
|
||||
<div className="mt-1.5">
|
||||
<SettingsHint>
|
||||
{t(local ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
|
||||
</SettingsHint>
|
||||
</div>
|
||||
</DialogSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Cable, type LucideIcon, MessagesSquare, ScanText } from 'lucide-react';
|
||||
import type { Addon } from './addonModel';
|
||||
|
||||
/**
|
||||
* The three addons that talk to a language model, shown as one AI tile in the
|
||||
* integration column: the assistant in the trip, AI parsing (booking import)
|
||||
* and MCP (TREK inside other AI apps). Their ids, rows and settings stay their
|
||||
* own; only the page groups them. Assistant and parsing share one connection.
|
||||
*/
|
||||
const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const;
|
||||
export type AiAddonId = (typeof AI_ADDON_IDS)[number];
|
||||
|
||||
/** Each one's glyph, keyed by id: the icon column of an existing install still holds what it was seeded with. */
|
||||
export const AI_ADDON_ICONS: Record<AiAddonId, LucideIcon> = {
|
||||
ai_assistant: MessagesSquare,
|
||||
llm_parsing: ScanText,
|
||||
mcp: Cable,
|
||||
};
|
||||
|
||||
export function isAiAddon(addon: Addon): boolean {
|
||||
return (AI_ADDON_IDS as readonly string[]).includes(addon.id);
|
||||
}
|
||||
|
||||
/** The AI addons this install has, in the tile's order. */
|
||||
export function aiAddonsOf(addons: Addon[]): Addon[] {
|
||||
return AI_ADDON_IDS.flatMap((id) => addons.find((a) => a.id === id) ?? []);
|
||||
}
|
||||
|
||||
/** Whether the shared connection is used at all: the assistant or AI parsing is on. */
|
||||
export function usesConnection(addons: Addon[]): boolean {
|
||||
return addons.some((a) => (a.id === 'ai_assistant' || a.id === 'llm_parsing') && a.enabled);
|
||||
}
|
||||
@@ -2,11 +2,13 @@ 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-006
|
||||
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-008
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
@@ -24,6 +26,8 @@ const ADDONS = [
|
||||
addon('llm_parsing', 'integration', false),
|
||||
addon('immich', 'photo_provider', false),
|
||||
addon('gdrive', 'document_provider'),
|
||||
addon('airtrail', 'integration', false),
|
||||
addon('mcp', 'integration'),
|
||||
];
|
||||
|
||||
let loadAddons: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
@@ -35,6 +39,10 @@ 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());
|
||||
|
||||
@@ -46,15 +54,24 @@ 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 and the two shelves', async () => {
|
||||
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration, AI and the two shelves', async () => {
|
||||
const { result, rerender } = await loaded();
|
||||
rerender();
|
||||
expect(adminApi.addons).toHaveBeenCalledTimes(1);
|
||||
const ids = (list: { id?: string; key?: string }[]) => list.map((a) => a.id ?? a.key);
|
||||
expect(ids(result.current.tripAddons)).toEqual(['todo', 'documents']);
|
||||
expect(ids(result.current.globalAddons)).toEqual(['journey']);
|
||||
expect(ids(result.current.integrationAddons)).toEqual(['llm_parsing']);
|
||||
// The AI addons leave the integration list for their own tile, in its order.
|
||||
expect(ids(result.current.integrationAddons)).toEqual(['airtrail']);
|
||||
expect(ids(result.current.aiAddons)).toEqual(['llm_parsing', 'mcp']);
|
||||
expect(result.current.providerOptions).toEqual([
|
||||
expect.objectContaining({ key: 'immich', label: 'immich', description: 'immich addon', enabled: false }),
|
||||
]);
|
||||
@@ -115,4 +132,43 @@ 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,11 +1,27 @@
|
||||
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
|
||||
@@ -20,6 +36,9 @@ 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 {
|
||||
@@ -32,10 +51,11 @@ export function useAddonManager() {
|
||||
|
||||
const loadOnMount = useEffectEvent(() => {
|
||||
void loadAddons().finally(() => setLoading(false));
|
||||
wandererAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursWanderer(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
const unreadable = () => toast.error(t('admin.addons.toast.error'));
|
||||
loadToursFlag(wandererAdminApi, setToursWanderer, unreadable);
|
||||
loadToursFlag(hikingMapAdminApi, setToursHikingMap, unreadable);
|
||||
loadToursFlag(cycleMapAdminApi, setToursCycleMap, unreadable);
|
||||
loadToursFlag(skiRoutingAdminApi, setToursSkiRouting, unreadable);
|
||||
});
|
||||
useEffect(() => {
|
||||
loadOnMount();
|
||||
@@ -63,20 +83,52 @@ export function useAddonManager() {
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
};
|
||||
|
||||
/** wanderer under Tours: flipped at once, put back when the save fails. */
|
||||
const toggleToursWanderer = async () => {
|
||||
const next = !toursWanderer;
|
||||
setToursWanderer(next);
|
||||
/** 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);
|
||||
try {
|
||||
await wandererAdminApi.update(next);
|
||||
await save(next);
|
||||
} catch {
|
||||
setToursWanderer(!next);
|
||||
set(!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,
|
||||
@@ -88,7 +140,9 @@ export function useAddonManager() {
|
||||
|
||||
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
|
||||
const globalAddons = addons.filter((a) => a.type === 'global');
|
||||
const integrationAddons = addons.filter((a) => a.type === 'integration');
|
||||
// The AI addons leave the list to sit together on one tile at its head.
|
||||
const integrationAddons = addons.filter((a) => a.type === 'integration' && !isAiAddon(a));
|
||||
const aiAddons = aiAddonsOf(addons);
|
||||
const providerOptions = addons.filter((a) => a.type === 'photo_provider').map(asOption);
|
||||
// No credential form under these, unlike the photo providers: a document connection
|
||||
// belongs to a trip and is entered there. The admin only decides whether a provider
|
||||
@@ -102,9 +156,11 @@ export function useAddonManager() {
|
||||
tripAddons,
|
||||
globalAddons,
|
||||
integrationAddons,
|
||||
aiAddons,
|
||||
providerOptions,
|
||||
documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import type { Addon } from './addonModel';
|
||||
import { usesConnection } from './aiAddons';
|
||||
|
||||
/**
|
||||
* The AI tile behind both admin shells: its three switches, how many are on,
|
||||
* and the settings dialog. The dialog opens by itself when OpenRouter's
|
||||
* sign-in returns here (`?ai=openrouter`), since the connection form that
|
||||
* finishes it lives inside.
|
||||
*/
|
||||
export function useAiAddonTile(aiAddons: Addon[]) {
|
||||
const [params] = useSearchParams();
|
||||
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
|
||||
const canSetUp = usesConnection(aiAddons);
|
||||
return {
|
||||
enabledCount: aiAddons.filter((a) => a.enabled).length,
|
||||
canSetUp,
|
||||
dialogOpen: open && canSetUp,
|
||||
openDialog: () => setOpen(true),
|
||||
closeDialog: () => setOpen(false),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
|
||||
import { MASKED_SETTING_VALUE } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
adminSettings: vi.fn(),
|
||||
saveAdminSettings: vi.fn(),
|
||||
adminConnection: vi.fn(),
|
||||
saveAdminConnection: vi.fn(),
|
||||
removeAdminConnection: vi.fn(),
|
||||
adminModels: vi.fn(),
|
||||
}));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const { useAiAdminConfig } = await import('./useAiAdminConfig');
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: ['/admin?tab=addons'] },
|
||||
createElement(TranslationProvider, null, children)
|
||||
);
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
||||
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'anthropic',
|
||||
model: 'claude-sonnet',
|
||||
hint: '…a1b2',
|
||||
status: 'ok',
|
||||
host: 'api.anthropic.com',
|
||||
baseUrl: null,
|
||||
},
|
||||
prefill: null,
|
||||
};
|
||||
|
||||
async function renderLoaded() {
|
||||
const view = renderHook(() => useAiAdminConfig(), { wrapper });
|
||||
await waitFor(() => expect(view.result.current.loaded).toBe(true));
|
||||
return view;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAssistantStore.setState({ status: null });
|
||||
repo.status.mockRejectedValue(new Error('not needed'));
|
||||
repo.adminSettings.mockResolvedValue(SETTINGS);
|
||||
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
|
||||
repo.adminConnection.mockResolvedValue(NONE);
|
||||
repo.saveAdminConnection.mockResolvedValue(LIVE);
|
||||
repo.removeAdminConnection.mockResolvedValue(undefined);
|
||||
repo.adminModels.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]);
|
||||
});
|
||||
|
||||
describe('useAiAdminConfig', () => {
|
||||
it('FE-HOOK-AI-ADMIN-001: loads the settings and, without a connection, opens the setup for everyone', async () => {
|
||||
const { result } = await renderLoaded();
|
||||
expect(result.current.policy).toBe('instance_or_user');
|
||||
expect(result.current.policyHintKey).toBe('assistant.admin.policy.instanceOrUserHint');
|
||||
expect(result.current.instancePays).toBe(true);
|
||||
expect(result.current.showPanel).toBe(true);
|
||||
expect(result.current.dailyDraft).toBe('50');
|
||||
expect(result.current.retention).toBe('90');
|
||||
expect(result.current.retentionOptions.map((o) => o.label)).toEqual(['30 days', '90 days', '365 days', 'Forever']);
|
||||
expect(repo.status).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-002: a policy change saves at once, and a refused one is taken back', async () => {
|
||||
const { result } = await renderLoaded();
|
||||
await act(async () => result.current.setPolicy('user'));
|
||||
await waitFor(() => expect(result.current.policy).toBe('user'));
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, policy: 'user' });
|
||||
expect(result.current.instancePays).toBe(false);
|
||||
|
||||
repo.saveAdminSettings.mockRejectedValueOnce(new Error('nope'));
|
||||
await act(async () => result.current.setPolicy('instance'));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
|
||||
expect(result.current.policy).toBe('user');
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-003: the daily limit is kept within 1 to 10000 and saved when the field is left', async () => {
|
||||
const { result } = await renderLoaded();
|
||||
act(() => result.current.setDailyDraft('20000'));
|
||||
await act(async () => result.current.commitDailyLimit());
|
||||
expect(result.current.dailyDraft).toBe('10000');
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, dailyTurns: 10000 });
|
||||
|
||||
vi.clearAllMocks();
|
||||
act(() => result.current.setDailyDraft(''));
|
||||
await act(async () => result.current.commitDailyLimit());
|
||||
expect(result.current.dailyDraft).toBe('10000');
|
||||
expect(repo.saveAdminSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-004: a retention period set elsewhere stays selectable', async () => {
|
||||
repo.adminSettings.mockResolvedValue({ ...SETTINGS, retention: { mode: 'days', days: 120 } });
|
||||
const { result } = await renderLoaded();
|
||||
expect(result.current.retentionOptions.map((o) => o.value)).toEqual(['30', '90', '120', '365', 'forever']);
|
||||
await act(async () => result.current.setRetention('forever'));
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, retention: { mode: 'forever' } });
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-005: another model keeps the stored key by sending the mask', async () => {
|
||||
repo.adminConnection.mockResolvedValue(LIVE);
|
||||
const { result } = await renderLoaded();
|
||||
expect(result.current.showPanel).toBe(false);
|
||||
await waitFor(() => expect(result.current.instance.models).toHaveLength(1));
|
||||
await act(async () => result.current.instance.setModel('claude-haiku'));
|
||||
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
|
||||
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
|
||||
kind: 'anthropic',
|
||||
apiKey: MASKED_SETTING_VALUE,
|
||||
model: 'claude-haiku',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-006: an own server without a key sends none', async () => {
|
||||
repo.adminConnection.mockResolvedValue({
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
kind: 'custom',
|
||||
model: 'llama3',
|
||||
hint: null,
|
||||
status: 'ok',
|
||||
host: 'ollama:11434',
|
||||
baseUrl: 'http://ollama:11434/v1',
|
||||
},
|
||||
prefill: null,
|
||||
});
|
||||
const { result } = await renderLoaded();
|
||||
await act(async () => result.current.instance.setModel('qwen3:8b'));
|
||||
await waitFor(() => expect(repo.saveAdminConnection).toHaveBeenCalledTimes(1));
|
||||
expect(repo.saveAdminConnection).toHaveBeenCalledWith({
|
||||
kind: 'custom',
|
||||
baseUrl: 'http://ollama:11434/v1',
|
||||
model: 'qwen3:8b',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
|
||||
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
|
||||
const { result } = await renderLoaded();
|
||||
act(() => result.current.instance.replace());
|
||||
expect(result.current.showPanel).toBe(true);
|
||||
act(() => result.current.instance.cancelReplace());
|
||||
expect(result.current.showPanel).toBe(false);
|
||||
|
||||
act(() => result.current.instance.askRemove());
|
||||
expect(result.current.instance.confirmingRemove).toBe(true);
|
||||
act(() => result.current.instance.remove());
|
||||
await waitFor(() => expect(result.current.instance.live).toBeNull());
|
||||
expect(repo.removeAdminConnection).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.instance.confirmingRemove).toBe(false);
|
||||
expect(result.current.showPanel).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
MASKED_SETTING_VALUE,
|
||||
type AiAdminConnectionResponse,
|
||||
type AiAdminSettings,
|
||||
type AiInstanceConnectionRequest,
|
||||
type AiPolicy,
|
||||
type AiRetention,
|
||||
} from '@trek/shared';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { providerLabel } from '../../Assistant/providerCatalog';
|
||||
import { connectErrorKey } from '../../Settings/ai/aiConnectModel';
|
||||
import { useOpenrouterReturn } from '../../Settings/ai/useAiConnect';
|
||||
import { modelOptions, useAiModels } from '../../Settings/ai/useAiConnections';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
|
||||
/** Who pays, as the admin reads it ("I pay" rather than "the instance"); the first is the default. */
|
||||
export const POLICY_CHOICES: readonly { value: AiPolicy; labelKey: string; hintKey: string }[] = [
|
||||
{
|
||||
value: 'instance_or_user',
|
||||
labelKey: 'assistant.admin.policy.instanceOrUser',
|
||||
hintKey: 'assistant.admin.policy.instanceOrUserHint',
|
||||
},
|
||||
{ value: 'instance', labelKey: 'assistant.admin.policy.instance', hintKey: 'assistant.admin.policy.instanceHint' },
|
||||
{ value: 'user', labelKey: 'assistant.admin.policy.user', hintKey: 'assistant.admin.policy.userHint' },
|
||||
];
|
||||
|
||||
const RETENTION_DAYS = [30, 90, 365];
|
||||
const FOREVER = 'forever';
|
||||
const MAX_DAILY_TURNS = 10_000;
|
||||
|
||||
/** A retention as the select's value: its days, or `forever`. */
|
||||
function retentionValue(retention: AiRetention): string {
|
||||
return retention.mode === 'forever' ? FOREVER : String(retention.days);
|
||||
}
|
||||
|
||||
function retentionOf(value: string): AiRetention {
|
||||
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
|
||||
}
|
||||
|
||||
/** Policy, daily limit and retention: each change is saved at once and taken back when the server refuses it. */
|
||||
function useAdminSettings(onSaved: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<AiAdminSettings | null>(null);
|
||||
const [dailyDraft, setDailyDraft] = useState('');
|
||||
|
||||
const adopt = (next: AiAdminSettings) => {
|
||||
setSettings(next);
|
||||
setDailyDraft(String(next.dailyTurns));
|
||||
};
|
||||
|
||||
const save = async (patch: Partial<AiAdminSettings>) => {
|
||||
if (!settings) return;
|
||||
const before = settings;
|
||||
setSettings({ ...settings, ...patch });
|
||||
try {
|
||||
adopt(await aiConnectionsRepo.saveAdminSettings({ ...settings, ...patch }));
|
||||
onSaved();
|
||||
} catch {
|
||||
adopt(before);
|
||||
toast.error(t('assistant.error.generic'));
|
||||
}
|
||||
};
|
||||
|
||||
/** The typed limit, kept within what the server takes, saved when the field is left. */
|
||||
const commitDailyLimit = () => {
|
||||
if (!settings) return;
|
||||
const typed = Number.parseInt(dailyDraft, 10);
|
||||
const next = Number.isFinite(typed) ? Math.min(MAX_DAILY_TURNS, Math.max(1, typed)) : settings.dailyTurns;
|
||||
setDailyDraft(String(next));
|
||||
if (next !== settings.dailyTurns) void save({ dailyTurns: next });
|
||||
};
|
||||
|
||||
const current = settings ? retentionValue(settings.retention) : '';
|
||||
// A period set some other way stays selectable instead of reading as blank.
|
||||
const unlisted = current !== '' && current !== FOREVER && !RETENTION_DAYS.includes(Number(current));
|
||||
const days = unlisted ? [...RETENTION_DAYS, Number(current)].sort((a, b) => a - b) : RETENTION_DAYS;
|
||||
|
||||
return {
|
||||
settings,
|
||||
adopt,
|
||||
setPolicy: (policy: AiPolicy) => void save({ policy }),
|
||||
dailyDraft,
|
||||
setDailyDraft,
|
||||
commitDailyLimit,
|
||||
retention: current,
|
||||
retentionOptions: [
|
||||
...days.map((d) => ({ value: String(d), label: t('assistant.admin.retentionDays', { count: d }) })),
|
||||
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
|
||||
],
|
||||
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The connection for everyone: shown, its model switched, replaced or removed. */
|
||||
function useInstanceConnection(onChanged: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [connection, setConnection] = useState<AiAdminConnectionResponse | null>(null);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const [confirmingRemove, setConfirmingRemove] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const live = connection?.connection ?? null;
|
||||
const provider = live ? providerLabel(live.kind, live.host) : '';
|
||||
const models = useAiModels(
|
||||
live?.status === 'ok' ? () => aiConnectionsRepo.adminModels() : null,
|
||||
live ? `${live.kind}@${live.host}` : ''
|
||||
);
|
||||
|
||||
const act = async (action: () => Promise<AiAdminConnectionResponse>, doneKey: string | null) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setConnection(await action());
|
||||
onChanged();
|
||||
if (doneKey) toast.success(t(doneKey));
|
||||
} catch (err) {
|
||||
toast.error(t(connectErrorKey(err), { provider }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
|
||||
const setModel = (model: string) => {
|
||||
if (!live || live.kind === 'chatgpt') return;
|
||||
const request: AiInstanceConnectionRequest =
|
||||
live.kind === 'custom'
|
||||
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
|
||||
: { kind: live.kind, apiKey: MASKED_SETTING_VALUE, model };
|
||||
void act(() => aiConnectionsRepo.saveAdminConnection(request), 'common.saved');
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
setConfirmingRemove(false);
|
||||
void act(async () => {
|
||||
await aiConnectionsRepo.removeAdminConnection();
|
||||
return aiConnectionsRepo.adminConnection();
|
||||
}, null);
|
||||
};
|
||||
|
||||
const onConnected = () => {
|
||||
setReplacing(false);
|
||||
void act(() => aiConnectionsRepo.adminConnection(), null);
|
||||
};
|
||||
|
||||
return {
|
||||
connection,
|
||||
setConnection,
|
||||
live,
|
||||
provider,
|
||||
models: live
|
||||
? modelOptions(models, live.model, {
|
||||
free: t('assistant.model.free'),
|
||||
recommended: t('assistant.model.recommended'),
|
||||
})
|
||||
: [],
|
||||
busy,
|
||||
setModel,
|
||||
replacing,
|
||||
replace: () => setReplacing(true),
|
||||
cancelReplace: () => setReplacing(false),
|
||||
onConnected,
|
||||
confirmingRemove,
|
||||
askRemove: () => setConfirmingRemove(true),
|
||||
cancelRemove: () => setConfirmingRemove(false),
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The AI assistant's admin settings behind both admin shells, on the addon's
|
||||
* own tile: who pays, the connection everyone runs on, the daily limit while
|
||||
* the instance pays, and how long chats are kept. The assistant's status is
|
||||
* reloaded after every change, so the launcher appears or goes at once.
|
||||
*/
|
||||
export function useAiAdminConfig() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const refreshStatus = () => void loadStatus();
|
||||
const form = useAdminSettings(refreshStatus);
|
||||
const instance = useInstanceConnection(refreshStatus);
|
||||
useOpenrouterReturn(instance.onConnected);
|
||||
|
||||
const loadOnMount = useEffectEvent((isCancelled: () => boolean) => {
|
||||
Promise.all([aiConnectionsRepo.adminSettings(), aiConnectionsRepo.adminConnection()]).then(
|
||||
([settings, connection]) => {
|
||||
if (isCancelled()) return;
|
||||
form.adopt(settings);
|
||||
instance.setConnection(connection);
|
||||
},
|
||||
() => {
|
||||
if (!isCancelled()) toast.error(t('assistant.error.generic'));
|
||||
}
|
||||
);
|
||||
// The connect panel reads whether OpenRouter can return here from the status.
|
||||
refreshStatus();
|
||||
});
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadOnMount(() => cancelled);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const policy = form.settings?.policy ?? null;
|
||||
return {
|
||||
...form,
|
||||
instance,
|
||||
loaded: form.settings !== null && instance.connection !== null,
|
||||
policy,
|
||||
policyHintKey: POLICY_CHOICES.find((c) => c.value === policy)?.hintKey ?? null,
|
||||
/** Policy `user` charges nothing to the instance: no connection and no limit to set. */
|
||||
instancePays: policy !== null && policy !== 'user',
|
||||
showPanel: instance.live === null || instance.replacing,
|
||||
};
|
||||
}
|
||||
@@ -5,10 +5,14 @@ import { adminApi } from '../../../api/client';
|
||||
import { type Addon, DEFAULT_OLLAMA_URL, MASKED } from './addonModel';
|
||||
import { useLlmParsingConfig } from './useLlmParsingConfig';
|
||||
|
||||
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-011
|
||||
// FE-HOOK-LLMCFG-001 to FE-HOOK-LLMCFG-013
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
|
||||
|
||||
/** A local connection of parsing's own, so the form is open and lists the server's models. */
|
||||
const OWN_LOCAL = { provider: 'local', model: 'm' };
|
||||
|
||||
function llm(config?: Record<string, unknown>): Addon {
|
||||
return {
|
||||
@@ -45,7 +49,7 @@ describe('useLlmParsingConfig', () => {
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-002: the local provider lists the installed models from the default URL', async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
||||
expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL);
|
||||
expect(result.current.isInstalled('qwen3.5:4b')).toBe(true);
|
||||
@@ -54,7 +58,7 @@ describe('useLlmParsingConfig', () => {
|
||||
|
||||
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
|
||||
useLlmParsingConfig(llm({ provider: 'openai', model: 'gpt', baseUrl: ' http://box:11434/v1 ' }))
|
||||
);
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
await act(() => result.current.loadModels());
|
||||
@@ -69,13 +73,13 @@ describe('useLlmParsingConfig', () => {
|
||||
|
||||
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.modelsErr).toBe('ECONNREFUSED'));
|
||||
expect(result.current.installed).toEqual([]);
|
||||
expect(result.current.loadingModels).toBe(false);
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue('nope');
|
||||
await act(() => result.current.loadModels());
|
||||
expect(result.current.modelsErr).toBe('Could not reach the local LLM server');
|
||||
expect(result.current.modelsErr).toBe('admin.addons.ai.parsing.unreachable');
|
||||
});
|
||||
|
||||
it("FE-HOOK-LLMCFG-011: a refused list shows the server's reason, not the status line (#2649)", async () => {
|
||||
@@ -83,7 +87,7 @@ describe('useLlmParsingConfig', () => {
|
||||
response: { status: 502, data: { error: 'Local LLM server rejected the API key (401)' } },
|
||||
});
|
||||
vi.mocked(adminApi.llmLocalModels).mockRejectedValue(refused);
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.modelsErr).toBe('Local LLM server rejected the API key (401)'));
|
||||
});
|
||||
|
||||
@@ -94,14 +98,14 @@ describe('useLlmParsingConfig', () => {
|
||||
progress = onProgress;
|
||||
return new Promise<void>((r) => (finish = r));
|
||||
});
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm()));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm(OWN_LOCAL)));
|
||||
await waitFor(() => expect(result.current.installed).toEqual(['qwen3.5:4b']));
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.pull('qwen3.5:4b');
|
||||
});
|
||||
expect(result.current.pulling).toBe('qwen3.5:4b');
|
||||
expect(result.current.pullStatus).toBe('starting…');
|
||||
expect(result.current.pullStatus).toBe('admin.addons.ai.parsing.pulling');
|
||||
act(() => progress({ status: 'downloading', total: 200, completed: 50 }));
|
||||
expect(result.current.pullStatus).toBe('downloading');
|
||||
expect(result.current.pullPct).toBe(25);
|
||||
@@ -109,7 +113,7 @@ describe('useLlmParsingConfig', () => {
|
||||
finish();
|
||||
await pending;
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Model pulled');
|
||||
expect(toast.success).toHaveBeenCalledWith('admin.addons.ai.parsing.pulled');
|
||||
expect(result.current.model).toBe('qwen3.5:4b');
|
||||
expect(adminApi.llmLocalModels).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.pulling).toBeNull();
|
||||
@@ -136,7 +140,7 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(toast.error).toHaveBeenCalledWith('manifest unknown');
|
||||
vi.mocked(adminApi.llmLocalPull).mockRejectedValue('nope');
|
||||
await act(() => result.current.pull('x'));
|
||||
expect(toast.error).toHaveBeenLastCalledWith('Pull failed');
|
||||
expect(toast.error).toHaveBeenLastCalledWith('admin.addons.ai.parsing.pullFailed');
|
||||
expect(result.current.pulling).toBeNull();
|
||||
});
|
||||
|
||||
@@ -148,18 +152,17 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', {
|
||||
config: { provider: 'openai', model: 'gpt-4o', baseUrl: 'https://o/v1', apiKey: MASKED, vision: 'auto' },
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Saved');
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.aiParsing.toast.saved');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
|
||||
act(() => {
|
||||
result.current.setProvider('anthropic');
|
||||
result.current.setVision('off');
|
||||
});
|
||||
await act(async () => result.current.setVision('off'));
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
await act(() => result.current.save());
|
||||
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1]).toEqual({
|
||||
const calls = vi.mocked(adminApi.updateAddon).mock.calls;
|
||||
expect(calls[calls.length - 1]?.[1]).toEqual({
|
||||
config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' },
|
||||
});
|
||||
});
|
||||
@@ -212,7 +215,35 @@ describe('useLlmParsingConfig', () => {
|
||||
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
|
||||
await act(() => result.current.save());
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to save');
|
||||
expect(toast.error).toHaveBeenCalledWith('settings.aiParsing.toast.saveError');
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-012: without its own connection the form stays shut and asks the server nothing until opened', async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ vision: 'on' })));
|
||||
expect(result.current.ownConnection).toBe(false);
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
act(() => result.current.startOwn());
|
||||
expect(result.current.formOpen).toBe(true);
|
||||
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
|
||||
// The vision setting alone is saved without a connection.
|
||||
await act(async () => result.current.setVision('off'));
|
||||
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('llm_parsing', { config: { vision: 'off' } });
|
||||
act(() => result.current.cancelOwn());
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-013: going back to the shared connection drops the own one and keeps the vision setting', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'claude-x', apiKey: MASKED, vision: 'on' }))
|
||||
);
|
||||
expect(result.current.ownConnection).toBe(true);
|
||||
expect(result.current.savedModel).toBe('claude-x');
|
||||
await act(() => result.current.switchToShared());
|
||||
expect(adminApi.updateAddon).toHaveBeenCalledWith('llm_parsing', { config: { vision: 'on' } });
|
||||
expect(result.current.ownConnection).toBe(false);
|
||||
expect(result.current.formOpen).toBe(false);
|
||||
expect(result.current.model).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,33 +2,118 @@ import { type LlmVision, asLlmVision } from '@trek/shared';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config behind both admin shells. When set, it applies to the
|
||||
* whole instance and overrides per-user config (see server llmConfig.ts). The API key is
|
||||
* masked on read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider it also lists the installed Ollama models and can pull the recommended ones.
|
||||
*/
|
||||
export function useLlmParsingConfig(addon: Addon) {
|
||||
const toast = useToast();
|
||||
const cfg = addon.config ?? {};
|
||||
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
|
||||
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
|
||||
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
|
||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
|
||||
const [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision));
|
||||
const [saving, setSaving] = useState(false);
|
||||
/** What AI parsing keeps in its addon config: a connection of its own (or none) and the vision setting. */
|
||||
interface SavedParsing {
|
||||
provider: string;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
vision: LlmVision;
|
||||
}
|
||||
|
||||
// Local-provider model management.
|
||||
function savedOf(cfg: Record<string, unknown>): SavedParsing {
|
||||
return {
|
||||
provider: typeof cfg.provider === 'string' ? cfg.provider : '',
|
||||
model: typeof cfg.model === 'string' ? cfg.model : '',
|
||||
baseUrl: typeof cfg.baseUrl === 'string' ? cfg.baseUrl : '',
|
||||
apiKey: typeof cfg.apiKey === 'string' ? cfg.apiKey : '',
|
||||
vision: asLlmVision(cfg.vision),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The models a local (Ollama) server has, and pulling the recommended ones.
|
||||
* The list loads while the form shows the local provider: a provider change or
|
||||
* opening the form loads it, the address field's own blur reloads it.
|
||||
*/
|
||||
function useLocalModels(baseUrl: string, isLocal: boolean, formOpen: boolean, onPulled: (id: string) => void) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [installed, setInstalled] = useState<string[]>([]);
|
||||
const [modelsErr, setModelsErr] = useState('');
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [pulling, setPulling] = useState<string | null>(null);
|
||||
const [pullPct, setPullPct] = useState(0);
|
||||
const [pullStatus, setPullStatus] = useState('');
|
||||
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
|
||||
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
|
||||
|
||||
const loadModels = async () => {
|
||||
if (!isLocal) return;
|
||||
setLoadingModels(true);
|
||||
setModelsErr('');
|
||||
try {
|
||||
const res = await adminApi.llmLocalModels(effectiveUrl);
|
||||
setInstalled(res.models.map((m) => m.name));
|
||||
} catch (e: unknown) {
|
||||
// The server says why (unreachable, key refused); axios' own status line does not.
|
||||
setModelsErr(getApiErrorMessage(e, t('admin.addons.ai.parsing.unreachable')));
|
||||
setInstalled([]);
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadModelsForProvider = useEffectEvent(() => {
|
||||
if (formOpen && isLocal) void loadModels();
|
||||
});
|
||||
useEffect(() => {
|
||||
loadModelsForProvider();
|
||||
}, [isLocal, formOpen]);
|
||||
|
||||
const pull = async (id: string) => {
|
||||
if (pulling) return;
|
||||
setPulling(id);
|
||||
setPullPct(0);
|
||||
setPullStatus(t('admin.addons.ai.parsing.pulling'));
|
||||
try {
|
||||
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
|
||||
if (p.error) throw new Error(p.error);
|
||||
if (p.status) setPullStatus(p.status);
|
||||
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
|
||||
});
|
||||
toast.success(t('admin.addons.ai.parsing.pulled'));
|
||||
onPulled(id);
|
||||
await loadModels();
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error && e.message ? e.message : t('admin.addons.ai.parsing.pullFailed'));
|
||||
} finally {
|
||||
setPulling(null);
|
||||
setPullPct(0);
|
||||
setPullStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
return { installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull };
|
||||
}
|
||||
|
||||
/**
|
||||
* AI parsing's admin settings behind both admin shells. Parsing reads with the
|
||||
* AI connection the assistant runs on unless the instance keeps one of its own
|
||||
* here; an install set up before the two shared one keeps its own until the
|
||||
* admin switches it over. The API key is masked on read and an unchanged mask
|
||||
* keeps the stored key. For a local server it lists the installed Ollama models
|
||||
* and pulls the recommended ones.
|
||||
*/
|
||||
export function useLlmParsingConfig(addon: Addon) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [saved, setSaved] = useState<SavedParsing>(() => savedOf(addon.config ?? {}));
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [provider, setProviderState] = useState<string>(saved.provider || 'local');
|
||||
const [model, setModel] = useState<string>(saved.model);
|
||||
const [baseUrl, setBaseUrl] = useState<string>(saved.baseUrl);
|
||||
const [apiKey, setApiKey] = useState<string>(saved.apiKey);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
/** A connection of its own, which wins over the shared one. */
|
||||
const ownConnection = !!(saved.provider && saved.model);
|
||||
const formOpen = ownConnection || editing;
|
||||
|
||||
/**
|
||||
* A different provider never inherits the last one's endpoint: an Ollama address
|
||||
@@ -37,83 +122,58 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
* keep an address the admin typed for it (#2655); empty means the provider default.
|
||||
*/
|
||||
const setProvider = (next: string) => {
|
||||
if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : '');
|
||||
if (next !== provider) setBaseUrl(next === saved.provider ? saved.baseUrl : '');
|
||||
setProviderState(next);
|
||||
};
|
||||
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
|
||||
const local = useLocalModels(baseUrl, provider === 'local', formOpen, setModel);
|
||||
|
||||
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
|
||||
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
|
||||
|
||||
const loadModels = async () => {
|
||||
if (provider !== 'local') return;
|
||||
setLoadingModels(true);
|
||||
setModelsErr('');
|
||||
try {
|
||||
const res = await adminApi.llmLocalModels(effectiveUrl);
|
||||
setInstalled(res.models.map((m) => m.name));
|
||||
} catch (e: unknown) {
|
||||
// The server says why (unreachable, key refused); axios' own status line does not.
|
||||
setModelsErr(getApiErrorMessage(e, 'Could not reach the local LLM server'));
|
||||
setInstalled([]);
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load installed models when the local provider is active. Only a provider change
|
||||
// triggers it; the base URL is read as it stands then (its own blur reloads).
|
||||
const loadModelsForProvider = useEffectEvent(() => {
|
||||
if (provider === 'local') void loadModels();
|
||||
});
|
||||
useEffect(() => {
|
||||
loadModelsForProvider();
|
||||
}, [provider]);
|
||||
|
||||
const pull = async (id: string) => {
|
||||
if (pulling) return;
|
||||
setPulling(id);
|
||||
setPullPct(0);
|
||||
setPullStatus('starting…');
|
||||
try {
|
||||
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
|
||||
if (p.error) throw new Error(p.error);
|
||||
if (p.status) setPullStatus(p.status);
|
||||
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
|
||||
});
|
||||
toast.success('Model pulled');
|
||||
setModel(id);
|
||||
await loadModels();
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Pull failed');
|
||||
} finally {
|
||||
setPulling(null);
|
||||
setPullPct(0);
|
||||
setPullStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
/** Writes the config; the stored key stays as long as the mask is sent in its place. */
|
||||
const write = async (next: SavedParsing, config: Record<string, unknown>): Promise<boolean> => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Send the masked sentinel unchanged so the server keeps the stored key.
|
||||
await adminApi.updateAddon(addon.id, {
|
||||
config: {
|
||||
provider,
|
||||
model: model.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiKey,
|
||||
vision,
|
||||
},
|
||||
});
|
||||
toast.success('Saved');
|
||||
await adminApi.updateAddon(addon.id, { config });
|
||||
setSaved(next);
|
||||
toast.success(t('settings.aiParsing.toast.saved'));
|
||||
return true;
|
||||
} catch {
|
||||
toast.error('Failed to save');
|
||||
toast.error(t('settings.aiParsing.toast.saveError'));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ownConfig = (s: SavedParsing) => ({
|
||||
provider: s.provider,
|
||||
model: s.model,
|
||||
baseUrl: s.baseUrl,
|
||||
apiKey: s.apiKey,
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
const next: SavedParsing = { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, vision: saved.vision };
|
||||
if (await write(next, { ...ownConfig(next), vision: next.vision })) setEditing(false);
|
||||
};
|
||||
|
||||
/** Back on the shared connection: the own one and its key are dropped, the vision setting stays. */
|
||||
const switchToShared = async () => {
|
||||
const next: SavedParsing = { provider: '', model: '', baseUrl: '', apiKey: '', vision: saved.vision };
|
||||
if (await write(next, { vision: next.vision })) {
|
||||
setEditing(false);
|
||||
setProviderState('local');
|
||||
setModel('');
|
||||
setBaseUrl('');
|
||||
setApiKey('');
|
||||
}
|
||||
};
|
||||
|
||||
/** Saved at once, with the connection as it was saved: a draft in the form is not sent along. */
|
||||
const setVision = (value: LlmVision) => {
|
||||
const next = { ...saved, vision: value };
|
||||
void write(next, ownConnection ? { ...ownConfig(saved), vision: value } : { vision: value });
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
setProvider,
|
||||
@@ -124,18 +184,17 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
baseUrlPlaceholder,
|
||||
apiKey,
|
||||
setApiKey,
|
||||
vision,
|
||||
vision: saved.vision,
|
||||
setVision,
|
||||
saving,
|
||||
installed,
|
||||
modelsErr,
|
||||
loadingModels,
|
||||
pulling,
|
||||
pullPct,
|
||||
pullStatus,
|
||||
isInstalled,
|
||||
loadModels,
|
||||
pull,
|
||||
ownConnection,
|
||||
savedProvider: saved.provider,
|
||||
savedModel: saved.model,
|
||||
formOpen,
|
||||
startOwn: () => setEditing(true),
|
||||
cancelOwn: () => setEditing(false),
|
||||
switchToShared,
|
||||
...local,
|
||||
save,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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-021: the paddle regions card in the admin area, drawn from a
|
||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: 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,11 +25,13 @@ 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,
|
||||
};
|
||||
}
|
||||
@@ -56,6 +58,8 @@ 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(),
|
||||
@@ -63,6 +67,8 @@ 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(),
|
||||
@@ -295,6 +301,91 @@ 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,6 +15,7 @@ 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';
|
||||
|
||||
@@ -22,8 +23,10 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
/**
|
||||
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export default function PaddleRegionsPanel() {
|
||||
return (
|
||||
@@ -67,6 +70,8 @@ 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>
|
||||
@@ -216,6 +221,41 @@ 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;
|
||||
@@ -239,10 +279,12 @@ 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}>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
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,11 +24,15 @@ 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')>()),
|
||||
paddleRegionRepo: repo,
|
||||
// The list answers its regions and no requests unless a test says otherwise (PAD-11C).
|
||||
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
|
||||
}));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
@@ -40,11 +44,13 @@ 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 } from '@trek/shared';
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -23,12 +24,14 @@ function errorText(t: Translate, err: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* 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);
|
||||
@@ -38,7 +41,8 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||
paddleRegionRepo.list().then(
|
||||
(list) => {
|
||||
if (cancelled) return;
|
||||
setRegions(list);
|
||||
setRegions(list.regions);
|
||||
setRequests(list.requests);
|
||||
setError(null);
|
||||
setLoaded(true);
|
||||
},
|
||||
@@ -59,7 +63,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, loading: !loaded && !offline, error, reload };
|
||||
return { regions, requests, loading: !loaded && !offline, error, reload };
|
||||
}
|
||||
|
||||
/** Geofabrik's list searched as the admin types, the last search winning. */
|
||||
@@ -99,7 +103,8 @@ 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.
|
||||
* 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.
|
||||
*/
|
||||
export function usePaddleRegionsAdmin() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,6 +112,7 @@ 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);
|
||||
@@ -146,6 +152,12 @@ 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);
|
||||
@@ -157,6 +169,8 @@ export function usePaddleRegionsAdmin() {
|
||||
toursOn,
|
||||
offline,
|
||||
regions: list.regions,
|
||||
requests: list.requests,
|
||||
autoLoad,
|
||||
loading: list.loading,
|
||||
error: list.error,
|
||||
retry: reload,
|
||||
@@ -165,6 +179,8 @@ export function usePaddleRegionsAdmin() {
|
||||
busyId,
|
||||
add,
|
||||
refresh,
|
||||
approve,
|
||||
decline,
|
||||
removing,
|
||||
askRemove: setRemoving,
|
||||
cancelRemove: () => setRemoving(null),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* FE-AI-ASK-001 to FE-AI-ASK-003
|
||||
*
|
||||
* "Ask" in the place inspector: there only while the assistant can run, only
|
||||
* in the planner of the place's trip, and it opens the chat about the place.
|
||||
*/
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildPlace } from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import AssistantAskButton from './AssistantAskButton';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const place = buildPlace({ id: 42, trip_id: 5, name: 'Kinkaku-ji' });
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/trips/:id" element={<AssistantAskButton place={place} />} />
|
||||
<Route path="*" element={<AssistantAskButton place={place} />} />
|
||||
</Routes>,
|
||||
{ initialEntries: [path] }
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => useAssistantStore.setState(INITIAL, true));
|
||||
|
||||
describe('AssistantAskButton', () => {
|
||||
it('FE-AI-ASK-001: shows nothing until the assistant has a connection to use', () => {
|
||||
renderAt('/trips/5');
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-ASK-002: opens the assistant with the place as its context', () => {
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
|
||||
renderAt('/trips/5');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ask: Kinkaku-ji' }));
|
||||
|
||||
const state = useAssistantStore.getState();
|
||||
expect(state.window).toEqual({ open: true, minimized: false });
|
||||
expect(chatOf(state, 5).focus).toEqual({ placeIds: [42] });
|
||||
});
|
||||
|
||||
it('FE-AI-ASK-003: is not offered outside the planner of the trip the place belongs to', () => {
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
|
||||
const { unmount } = renderAt('/trips/6');
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderAt('/collections/3');
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useMatch } from 'react-router';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { Place } from '../../types';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import { useAssistantAsk } from './useAssistantAsk';
|
||||
|
||||
/**
|
||||
* The place inspector's "Ask": a round button in its head, beside close, that
|
||||
* opens the assistant about this place. Only in the planner of the place's own
|
||||
* trip; a saved place in a collection has no trip to ask about.
|
||||
*/
|
||||
export default function AssistantAskButton({ place }: { place: Place }) {
|
||||
const { t } = useTranslation();
|
||||
const inTrip = useMatch('/trips/:id');
|
||||
const tripId = inTrip && Number(inTrip.params.id) === place.trip_id ? place.trip_id : null;
|
||||
const { available, askAbout } = useAssistantAsk(tripId);
|
||||
if (!available) return null;
|
||||
|
||||
const label = t('assistant.context.ask');
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => askAbout(place.id)}
|
||||
aria-label={`${label}: ${place.name}`}
|
||||
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content"
|
||||
>
|
||||
<Sparkles size={15} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* FE-AI-CARD-001 to FE-AI-CARD-013
|
||||
*
|
||||
* The change card: what it asks before anything is written, which decisions
|
||||
* one press sends, and what the receipt offers afterwards (the map, undo
|
||||
* while its window is open, and what a failed undo says). Then what ties it
|
||||
* to the map: a row lights its pin and the pin its row, the days it touches
|
||||
* read as a band, the phone hops to the map, and once applied the block can
|
||||
* be compared before and after and taken back as a whole.
|
||||
*/
|
||||
import type { AssistantApplied, AssistantChangeEffect, AssistantChangePreview } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantChangeCard from './AssistantChangeCard';
|
||||
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||
import type { ChangeItem } from './assistantParts';
|
||||
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
const { addToast } = vi.hoisted(() => ({ addToast: vi.fn() }));
|
||||
// The toasts a component raises, read as (message, type) the way the toast host takes them.
|
||||
vi.mock('../shared/Toast', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../shared/Toast')>()),
|
||||
useToast: () => ({
|
||||
success: (message: string) => addToast(message, 'success', undefined),
|
||||
error: (message: string) => addToast(message, 'error', undefined),
|
||||
warning: (message: string) => addToast(message, 'warning', undefined),
|
||||
info: (message: string) => addToast(message, 'info', undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
const TRIP = 4;
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const TRIP_INITIAL = useTripStore.getState();
|
||||
|
||||
const PANTHEON: AssistantChangeEffect = {
|
||||
kind: 'add',
|
||||
dayId: 3,
|
||||
fromDayId: null,
|
||||
placeId: null,
|
||||
assignmentId: null,
|
||||
place: { name: 'Pantheon', lat: 48.02, lng: 2.0, categoryId: null },
|
||||
};
|
||||
|
||||
function preview(toolCallId: string, subject: string): AssistantChangePreview {
|
||||
return {
|
||||
toolCallId,
|
||||
verbKey: 'addPlace',
|
||||
subject,
|
||||
rows: [
|
||||
{ op: 'add', labelKey: 'address', before: null, after: 'Kyoto' },
|
||||
{ op: 'change', labelKey: 'time', before: '09:00', after: '10:30' },
|
||||
],
|
||||
dayId: 3,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
};
|
||||
}
|
||||
|
||||
const pending = (id: string, subject: string): ChangeItem => ({
|
||||
toolCallId: id,
|
||||
preview: preview(id, subject),
|
||||
applied: null,
|
||||
state: 'pending',
|
||||
});
|
||||
|
||||
const onTheMap = (id: string, subject: string, effect = PANTHEON): ChangeItem => ({
|
||||
...pending(id, subject),
|
||||
preview: { ...preview(id, subject), effect },
|
||||
});
|
||||
|
||||
function receipt(id: string, undoUntil: string | null): ChangeItem {
|
||||
const applied: AssistantApplied = { toolCallId: id, entity: 'place', ids: [9], dayId: 3, undoUntil };
|
||||
return { toolCallId: id, preview: preview(id, 'Kinkaku-ji'), applied, state: 'applied' };
|
||||
}
|
||||
|
||||
function renderCard(items: ChangeItem[], view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={value}>
|
||||
<AssistantChangeCard items={items} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function patchChat(next: Partial<ReturnType<typeof chatOf>>) {
|
||||
useAssistantStore.setState((s) => ({ byTrip: { ...s.byTrip, [TRIP]: { ...chatOf(s, TRIP), ...next } } }));
|
||||
}
|
||||
|
||||
/** The planner holding this trip, with one stop on day 3. */
|
||||
function plannerHere(tripId = TRIP) {
|
||||
const louvre = buildPlace({ id: 10, name: 'Louvre', lat: 48.0, lng: 2.0 });
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: tripId }),
|
||||
days: [buildDay({ id: 3, title: 'Rome' })],
|
||||
assignments: { 3: [buildAssignment({ id: 100, day_id: 3, place: louvre, assignment_time: '09:00' })] },
|
||||
places: [louvre],
|
||||
});
|
||||
}
|
||||
|
||||
const future = () => new Date(Date.now() + 10 * 60_000).toISOString();
|
||||
|
||||
const decide = vi.fn<(tripId: number, decisions: { toolCallId: string; approved: boolean }[]) => Promise<void>>();
|
||||
const undo = vi.fn<(tripId: number, toolCallId: string) => Promise<'undone' | 'stale' | 'failed'>>();
|
||||
const undoBlock =
|
||||
vi.fn<(tripId: number, ids: readonly string[]) => Promise<{ done: number; stale: number; failed: number }>>();
|
||||
const setCompare = vi.fn<(tripId: number, mode: 'before' | 'after') => void>();
|
||||
const requestCamera = vi.fn();
|
||||
const setMinimized = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
decide.mockResolvedValue(undefined);
|
||||
useAssistantStore.setState({ decide, undo, undoBlock, setCompare, requestCamera, setMinimized });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
useTripStore.setState(TRIP_INITIAL, true);
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('AssistantChangeCard: deciding', () => {
|
||||
it('FE-AI-CARD-001: shows what changes in plain words, field by field, before anything is written', () => {
|
||||
renderCard([pending('c1', 'Kinkaku-ji')]);
|
||||
expect(screen.getByText('Kinkaku-ji')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add place')).toBeInTheDocument();
|
||||
// The faint line says when, old to new; the address is for the planner, not the card.
|
||||
expect(screen.getByText('09:00')).toBeInTheDocument();
|
||||
expect(screen.getByText('10:30')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Kyoto')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Include this change' })).toBeNull();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('1 change is waiting for you');
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-002: one change applies or discards with one press', () => {
|
||||
renderCard([pending('c1', 'Kinkaku-ji')]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(decide).toHaveBeenLastCalledWith(TRIP, [{ toolCallId: 'c1', approved: true }]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
|
||||
expect(decide).toHaveBeenLastCalledWith(TRIP, [{ toolCallId: 'c1', approved: false }]);
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-003: several changes each get a switch, and the button applies the ones left on in one request', () => {
|
||||
renderCard([pending('c1', 'Kinkaku-ji'), pending('c2', 'Ryoan-ji'), pending('c3', 'Nanzen-ji')]);
|
||||
expect(screen.getByRole('button', { name: 'Apply 3' })).toBeEnabled();
|
||||
|
||||
const [, second] = screen.getAllByRole('button', { name: 'Include this change' });
|
||||
fireEvent.click(second as HTMLElement);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply 2' }));
|
||||
|
||||
expect(decide).toHaveBeenCalledTimes(1);
|
||||
expect(decide).toHaveBeenCalledWith(TRIP, [
|
||||
{ toolCallId: 'c1', approved: true },
|
||||
{ toolCallId: 'c2', approved: false },
|
||||
{ toolCallId: 'c3', approved: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-004: nothing can be decided while the assistant is still answering', () => {
|
||||
patchChat({ isRunning: true });
|
||||
renderCard([pending('c1', 'Kinkaku-ji')]);
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Discard' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantChangeCard: the receipt', () => {
|
||||
it('FE-AI-CARD-005: an applied change shows on the map and can be undone until its window closes', () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date('2026-10-21T08:00:00.000Z'));
|
||||
const onShowOnMap = vi.fn();
|
||||
renderCard([receipt('c1', '2026-10-21T08:15:00.000Z')], { onShowOnMap });
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Applied' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
|
||||
expect(onShowOnMap).toHaveBeenCalledWith(expect.objectContaining({ toolCallId: 'c1', dayId: 3 }));
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(16 * 60_000);
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-006: an undone change says so, a stale one explains why it stays', async () => {
|
||||
// The store marks what it took back; the card reads it from there.
|
||||
undo.mockImplementationOnce(async (_tripId, toolCallId) => {
|
||||
patchChat({ undone: { [toolCallId]: true } });
|
||||
return 'undone';
|
||||
});
|
||||
const first = renderCard([receipt('c1', future())]);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
});
|
||||
expect(undo).toHaveBeenCalledWith(TRIP, 'c1');
|
||||
expect(screen.getByRole('img', { name: 'Undone' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
undo.mockResolvedValueOnce('stale');
|
||||
renderCard([receipt('c2', future())]);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith('Changed in the meantime. Please adjust it by hand.', 'warning', undefined);
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-007: no map button where nothing can show it, and none of the receipt actions after a discard', () => {
|
||||
renderCard([receipt('c1', null), { ...pending('c2', 'Ryoan-ji'), state: 'discarded' }]);
|
||||
expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull();
|
||||
expect(screen.getByRole('img', { name: 'Discarded' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantChangeCard: on the map', () => {
|
||||
it('FE-AI-CARD-008: resting on a row lights its pin, and a lit pin lights its row', () => {
|
||||
renderCard([onTheMap('c1', 'Pantheon'), pending('c2', 'Ryoan-ji')]);
|
||||
fireEvent.mouseEnter(screen.getByText('Pantheon'));
|
||||
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'c1', source: 'card' });
|
||||
fireEvent.mouseLeave(screen.getByText('Pantheon'));
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
|
||||
|
||||
// A change the plan does not show has no pin to light.
|
||||
fireEvent.mouseEnter(screen.getByText('Ryoan-ji'));
|
||||
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
|
||||
|
||||
const row = screen.getByText('Pantheon').closest('[class*="rounded-[12px]"]');
|
||||
expect(row).not.toBeNull();
|
||||
expect(row).not.toHaveClass('bg-surface-hover');
|
||||
act(() => useAssistantStore.setState({ mapLink: { tripId: TRIP, hoveredId: 'c1', source: 'map' } }));
|
||||
expect(row).toHaveClass('bg-surface-hover');
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-009: the head says whether the days still fit, without drawing the days again', () => {
|
||||
plannerHere();
|
||||
renderCard([onTheMap('c1', 'Pantheon')]);
|
||||
expect(screen.getByText(/Fits the time|to check/)).toBeInTheDocument();
|
||||
// The day list of the planner shows the suggestion; the card keeps to one line per change.
|
||||
expect(screen.getAllByText('Pantheon')).toHaveLength(1);
|
||||
expect(screen.queryByRole('button', { name: 'Show schedule' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-010: no check while the planner holds another trip, or once nothing waits', () => {
|
||||
plannerHere(TRIP + 1);
|
||||
const first = renderCard([onTheMap('c1', 'Pantheon')]);
|
||||
expect(screen.queryByText(/Fits the time|to check/)).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
plannerHere();
|
||||
renderCard([{ ...onTheMap('c1', 'Pantheon'), state: 'applied' }]);
|
||||
expect(screen.queryByText(/Fits the time|to check/)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-011: on the phone the card hops to the map and frames what it would change', () => {
|
||||
plannerHere();
|
||||
const onShowOnMap = vi.fn();
|
||||
const first = renderCard([onTheMap('c1', 'Pantheon')], { onShowOnMap });
|
||||
expect(screen.queryByRole('button', { name: 'View on map' })).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
renderCard([onTheMap('c1', 'Pantheon')], { variant: 'fullscreen', onShowOnMap });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View on map' }));
|
||||
expect(requestCamera).toHaveBeenCalledWith(
|
||||
TRIP,
|
||||
[
|
||||
[48.02, 2.0],
|
||||
[48.0, 2.0],
|
||||
],
|
||||
'fit',
|
||||
'user'
|
||||
);
|
||||
expect(setMinimized).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantChangeCard: the applied block', () => {
|
||||
const compare = (blockKey: string, mode: 'before' | 'after' = 'after') =>
|
||||
patchChat({ compare: { blockKey, mode, snapshot: { 3: [] }, newPlaceCalls: [], addedPlaceIds: [] } });
|
||||
|
||||
it('FE-AI-CARD-012: before and after are there only for the block this session can compare, beside a map', () => {
|
||||
compare('c1');
|
||||
const onShowOnMap = vi.fn();
|
||||
const first = renderCard([receipt('c1', null), receipt('c2', null)], { onShowOnMap });
|
||||
expect(screen.getByRole('button', { name: 'After' })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Before' }));
|
||||
expect(setCompare).toHaveBeenCalledWith(TRIP, 'before');
|
||||
first.unmount();
|
||||
|
||||
const other = renderCard([receipt('c7', null), receipt('c8', null)], { onShowOnMap });
|
||||
expect(screen.queryByRole('button', { name: 'Before' })).toBeNull();
|
||||
other.unmount();
|
||||
|
||||
renderCard([receipt('c1', null), receipt('c2', null)]);
|
||||
expect(screen.queryByRole('button', { name: 'Before' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-013: undo all takes back what still can be, and says how it went', async () => {
|
||||
const ids = ['c1', 'c2', 'c3'];
|
||||
const items = () => [receipt('c1', future()), receipt('c2', future()), receipt('c3', null)];
|
||||
const press = async () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo all' }));
|
||||
});
|
||||
};
|
||||
|
||||
undoBlock.mockResolvedValueOnce({ done: 2, stale: 0, failed: 0 });
|
||||
const first = renderCard(items());
|
||||
await press();
|
||||
expect(undoBlock).toHaveBeenCalledWith(TRIP, ids.slice(0, 2));
|
||||
expect(addToast).toHaveBeenLastCalledWith('2 changes undone', 'success', undefined);
|
||||
|
||||
undoBlock.mockResolvedValueOnce({ done: 1, stale: 1, failed: 0 });
|
||||
await press();
|
||||
expect(addToast).toHaveBeenLastCalledWith('1 undone, 1 changed since', 'warning', undefined);
|
||||
|
||||
undoBlock.mockResolvedValueOnce({ done: 0, stale: 0, failed: 2 });
|
||||
await press();
|
||||
expect(addToast).toHaveBeenLastCalledWith('2 changes could not be undone', 'error', undefined);
|
||||
first.unmount();
|
||||
|
||||
// One left to take back is the single undo button's job.
|
||||
patchChat({ undone: { c2: true } });
|
||||
renderCard(items());
|
||||
expect(screen.queryByRole('button', { name: 'Undo all' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantCompareSwitch', () => {
|
||||
it('follows whichever block is compared when it names none', () => {
|
||||
patchChat({ compare: { blockKey: 'c5', mode: 'before', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] } });
|
||||
render(<AssistantCompareSwitch tripId={TRIP} />);
|
||||
expect(screen.getByRole('button', { name: 'Before' })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'After' }));
|
||||
expect(setCompare).toHaveBeenCalledWith(TRIP, 'after');
|
||||
});
|
||||
|
||||
it("gives each side a thumb's room when tall", () => {
|
||||
patchChat({ compare: { blockKey: 'c5', mode: 'after', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] } });
|
||||
render(<AssistantCompareSwitch tripId={TRIP} tall />);
|
||||
expect(screen.getByRole('button', { name: 'Before' }).className).toContain('h-9');
|
||||
expect(screen.getByRole('button', { name: 'After' }).className).toContain('min-w-[44px]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,544 @@
|
||||
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' },
|
||||
addTour: { Icon: MapPinPlus, tone: 'success' },
|
||||
updateTour: { Icon: PencilLine, tone: 'info' },
|
||||
importTour: { Icon: Plus, tone: 'success' },
|
||||
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-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-caption text-content-faint">
|
||||
<span className="me-0.5 min-w-0 truncate">{verbLabel(preview, t)}</span>
|
||||
{preview.domain && (
|
||||
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
|
||||
{t(`assistant.domain.${preview.domain}`)}
|
||||
</span>
|
||||
)}
|
||||
<AssistantChangeFields rows={preview.rows} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's head: what the card is, what can be done with it as a whole, and how many changes it holds. */
|
||||
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-1 flex min-h-7 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-content-secondary">
|
||||
{t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||
</span>
|
||||
{actions}
|
||||
<span className="text-caption tabular-nums text-content-faint">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** On the phone the map hides behind the chat: this shrinks the chat and frames what the card would change. */
|
||||
function ViewOnMapButton({ changes }: { changes: readonly DayChange[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const requestCamera = useAssistantStore((s) => s.requestCamera);
|
||||
const setMinimized = useAssistantStore((s) => s.setMinimized);
|
||||
const show = () => {
|
||||
const { assignments, places } = useTripStore.getState();
|
||||
requestCamera(tripId, changePoints(changes, assignments, places), 'fit', 'user');
|
||||
setMinimized(true);
|
||||
};
|
||||
return (
|
||||
<Tooltip label={t('assistant.change.viewOnMap')} placement="top">
|
||||
<button type="button" onClick={show} aria-label={t('assistant.change.viewOnMap')} className={ICON_BUTTON}>
|
||||
<MapPin size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Takes back every change of the block that still can be, the last one first, and says how it went. */
|
||||
function UndoAllButton({ toolCallIds }: { toolCallIds: readonly string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { tripId } = useAssistantView();
|
||||
const undoBlock = useAssistantStore((s) => s.undoBlock);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { done, stale, failed } = await undoBlock(tripId, toolCallIds);
|
||||
if (failed > 0) toast.error(t('assistant.change.undoAllFailed', { count: failed }));
|
||||
else if (stale > 0) toast.warning(t('assistant.change.undoAllPartial', { done, stale }));
|
||||
else toast.success(t('assistant.change.undoneAll', { count: done }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Tooltip label={t('assistant.change.undoAll')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void run()}
|
||||
aria-label={t('assistant.change.undoAll')}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
|
||||
) : (
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the head offers: on a waiting card the way to the map on the phone, on a receipt the block as a whole. */
|
||||
function HeadActions({
|
||||
open,
|
||||
items,
|
||||
changes,
|
||||
undoable,
|
||||
}: {
|
||||
open: boolean;
|
||||
items: readonly ChangeItem[];
|
||||
changes: readonly DayChange[];
|
||||
undoable: readonly string[];
|
||||
}) {
|
||||
const { tripId, variant, onShowOnMap } = useAssistantView();
|
||||
const here = useTripStore((s) => s.trip?.id === tripId);
|
||||
if (open) {
|
||||
const toMap = variant === 'fullscreen' && !!onShowOnMap && here && changes.length > 0;
|
||||
return (
|
||||
<>
|
||||
<AssistantFitCheck changes={changes} />
|
||||
{toMap && <ViewOnMapButton changes={changes} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{onShowOnMap && <AssistantCompareSwitch tripId={tripId} blockKey={blockKeyOf(items.map((i) => i.toolCallId))} />}
|
||||
{undoable.length >= 2 && <UndoAllButton toolCallIds={undoable} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantChangeCard({ items }: { items: ChangeItem[] }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { tripId } = useAssistantView();
|
||||
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
|
||||
const undone = useAssistantStore((s) => chatOf(s, tripId).undone);
|
||||
const decide = useAssistantStore((s) => s.decide);
|
||||
const undo = useAssistantStore((s) => s.undo);
|
||||
const [excluded, setExcluded] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const now = useUndoClock(items);
|
||||
|
||||
const pending = items.filter((i) => i.state === 'pending');
|
||||
const chosen = pending.filter((i) => !excluded.has(i.toolCallId)).length;
|
||||
const changes = useMemo(() => dayChangesOf(items, excluded), [items, excluded]);
|
||||
const undoable = items.filter((i) => undoableNow(i, undone, now)).map((i) => i.toolCallId);
|
||||
|
||||
const week = useWeekDays(pending);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
// A plan over a week reads from its grid; the single changes wait behind one line.
|
||||
const folded = week.length > 0 && !showAll;
|
||||
|
||||
const toggleDay = (ids: readonly string[], include: boolean) =>
|
||||
setExcluded((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of ids) {
|
||||
if (include) next.delete(id);
|
||||
else next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggle = (toolCallId: string) =>
|
||||
setExcluded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(toolCallId)) next.add(toolCallId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const submit = (applyChosen: boolean) =>
|
||||
decide(
|
||||
tripId,
|
||||
pending.map((i) => ({ toolCallId: i.toolCallId, approved: applyChosen && !excluded.has(i.toolCallId) }))
|
||||
);
|
||||
|
||||
// An undone change reads as undone from the store; only what stays needs a word.
|
||||
const runUndo = async (toolCallId: string) => {
|
||||
const outcome = await undo(tripId, toolCallId);
|
||||
if (outcome === 'stale') toast.warning(t('assistant.change.undoStale'));
|
||||
else if (outcome === 'failed') toast.error(t('assistant.change.undoFailed'));
|
||||
};
|
||||
|
||||
const open = pending.length > 0;
|
||||
return (
|
||||
<section className="trek-popover-enter rounded-xl border border-edge-faint bg-surface-card px-3 pb-2 pt-2.5 shadow-card">
|
||||
<CardHead
|
||||
open={open}
|
||||
count={items.length}
|
||||
actions={<HeadActions open={open} items={items} changes={changes} undoable={undoable} />}
|
||||
/>
|
||||
{open && week.length > 0 && (
|
||||
<AssistantWeekOverview days={week} excluded={excluded} disabled={isRunning} onToggleDay={toggleDay} />
|
||||
)}
|
||||
{folded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(true)}
|
||||
className="mb-1 w-full rounded-lg px-2 py-1.5 text-center text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
{t('assistant.week.showAll', { count: pending.length })}
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
{items.map((item) =>
|
||||
item.state === 'pending' ? (
|
||||
folded ? null : (
|
||||
<PendingRow
|
||||
key={item.toolCallId}
|
||||
item={item}
|
||||
excluded={excluded.has(item.toolCallId)}
|
||||
withChoice={pending.length > 1}
|
||||
disabled={isRunning}
|
||||
onToggle={() => toggle(item.toolCallId)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Receipt
|
||||
key={item.toolCallId}
|
||||
item={item}
|
||||
undone={!!undone[item.toolCallId]}
|
||||
canUndo={undoable.includes(item.toolCallId)}
|
||||
onUndo={() => void runUndo(item.toolCallId)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<>
|
||||
<p className="sr-only" role="status">
|
||||
{t('assistant.change.pending', { count: pending.length })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 border-t border-edge-faint pt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRunning}
|
||||
onClick={() => void submit(false)}
|
||||
className="rounded-lg px-3 py-1.5 text-body text-content-secondary hover:bg-surface-hover hover:text-content disabled:opacity-50"
|
||||
>
|
||||
{t('assistant.change.discard')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRunning || chosen === 0}
|
||||
onClick={() => void submit(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-body font-medium text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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';
|
||||
import { SoftPill } from '../Planner/planParts';
|
||||
|
||||
/**
|
||||
* The few facts that tell a change apart at a glance, as badges on the line
|
||||
* under its name the way the planner's day list shows a place: when, what
|
||||
* kind, what it costs, a new order. A value that changes reads old to new.
|
||||
* The rest of what the change sets is not listed: the planner shows it once
|
||||
* applied.
|
||||
*/
|
||||
|
||||
/** The fields worth a place on the line, each with its glyph, in this order. */
|
||||
const KEY_FIELDS: Partial<Record<AssistantChangeLabelKey, LucideIcon>> = {
|
||||
day: CalendarDays,
|
||||
time: Clock,
|
||||
endTime: Clock,
|
||||
checkIn: LogIn,
|
||||
checkOut: LogOut,
|
||||
dueDate: CalendarClock,
|
||||
category: Tag,
|
||||
price: Wallet,
|
||||
quantity: Hash,
|
||||
order: ListOrdered,
|
||||
};
|
||||
const KEY_ORDER = Object.keys(KEY_FIELDS) as AssistantChangeLabelKey[];
|
||||
const MAX_FACTS = 3;
|
||||
|
||||
/** "#3" is how a day comes from the server; it reads as "Day 3". */
|
||||
function shown(row: AssistantChangeRow, value: string | null | undefined, dayWord: string): string {
|
||||
if (row.labelKey === 'day' && value && /^#\d+$/.test(value)) return `${dayWord} ${value.slice(1)}`;
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
export default function AssistantChangeFields({ rows }: { rows: AssistantChangeRow[] }) {
|
||||
const { t } = useTranslation();
|
||||
const dayWord = t('assistant.week.day');
|
||||
const facts = rows
|
||||
.filter((row) => row.labelKey in KEY_FIELDS && row.after)
|
||||
.sort((a, b) => KEY_ORDER.indexOf(a.labelKey) - KEY_ORDER.indexOf(b.labelKey))
|
||||
.slice(0, MAX_FACTS);
|
||||
if (facts.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{facts.map((row, i) => {
|
||||
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
|
||||
const changed = row.op !== 'add' && !!row.before;
|
||||
return (
|
||||
<SoftPill
|
||||
key={`${row.labelKey}-${i}`}
|
||||
className="max-w-full tabular-nums"
|
||||
icon={<Icon size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />}
|
||||
>
|
||||
{changed && (
|
||||
<>
|
||||
<bdi className="truncate text-content-faint line-through">{shown(row, row.before, dayWord)}</bdi>
|
||||
<ArrowRight size={9} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
|
||||
</>
|
||||
)}
|
||||
<bdi className="truncate">{shown(row, row.after, dayWord)}</bdi>
|
||||
</SoftPill>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
|
||||
/**
|
||||
* "Before" and "after" of the block of changes applied last, as a small
|
||||
* segmented switch: "before" lays the days it touched back the way they were
|
||||
* on the map, "after" shows the plan as it is. Only there while this session
|
||||
* holds the plan from before the block ran; a thread opened after a reload has
|
||||
* no switch. Without a `blockKey` it follows whichever block is compared.
|
||||
* `tall` gives each side a thumb's room, for the phone, where a tooltip
|
||||
* would never show.
|
||||
*/
|
||||
|
||||
const MODES = [
|
||||
{ mode: 'before', labelKey: 'assistant.change.before' },
|
||||
{ mode: 'after', labelKey: 'assistant.change.after' },
|
||||
] as const;
|
||||
|
||||
interface AssistantCompareSwitchProps {
|
||||
tripId: number;
|
||||
blockKey?: string;
|
||||
tall?: boolean;
|
||||
}
|
||||
|
||||
export default function AssistantCompareSwitch({ tripId, blockKey, tall = false }: AssistantCompareSwitchProps) {
|
||||
const { t } = useTranslation();
|
||||
const current = useAssistantStore((s) => {
|
||||
const compare = chatOf(s, tripId).compare;
|
||||
return compare && (blockKey === undefined || compare.blockKey === blockKey) ? compare.mode : null;
|
||||
});
|
||||
const setCompare = useAssistantStore((s) => s.setCompare);
|
||||
if (!current) return null;
|
||||
const label = t('assistant.change.compare');
|
||||
const group = (
|
||||
<div role="group" aria-label={label} className="inline-flex flex-shrink-0 rounded-lg bg-surface-secondary p-0.5">
|
||||
{MODES.map(({ mode, labelKey }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
aria-pressed={current === mode}
|
||||
onClick={() => setCompare(tripId, mode)}
|
||||
className={`rounded-md text-caption font-medium transition-colors ${
|
||||
tall ? 'h-9 min-w-[44px] px-3' : 'px-2 py-0.5'
|
||||
} ${current === mode ? 'bg-surface-card text-content shadow-card' : 'text-content-muted hover:text-content'}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (tall) return group;
|
||||
return (
|
||||
<Tooltip label={label} placement="top">
|
||||
{group}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* FE-AI-MENTION-001 to FE-AI-MENTION-007
|
||||
*
|
||||
* `@` in the composer on the real @assistant-ui runtime: the list of the
|
||||
* trip's days, places and bookings opens only at the start of a word, Enter
|
||||
* picks from it instead of sending, Escape closes it and keeps the text, and
|
||||
* a pick leaves `@label` in the text, with no chip above it: the text is what
|
||||
* the question names.
|
||||
*/
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildDay, buildPlace, buildReservation, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantSurface from './AssistantSurface';
|
||||
|
||||
const TRIP = 4;
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const INITIAL_TRIP = useTripStore.getState();
|
||||
|
||||
const STATUS: AiStatusResponse = {
|
||||
available: true,
|
||||
policy: 'instance',
|
||||
canConnectOwn: false,
|
||||
effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
|
||||
own: null,
|
||||
instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
|
||||
quota: null,
|
||||
privacyAcked: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
openrouterOAuth: false,
|
||||
};
|
||||
|
||||
const send = vi.fn(async () => {});
|
||||
|
||||
function seed(tripInPlanner = TRIP) {
|
||||
useAssistantStore.setState({
|
||||
status: STATUS,
|
||||
send,
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages: [] } },
|
||||
});
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: tripInPlanner, title: 'Paris' }),
|
||||
days: [buildDay({ id: 11, title: 'Arrival', date: '2026-11-02' }), buildDay({ id: 12, title: null, date: null })],
|
||||
places: [buildPlace({ id: 21, name: 'Louvre' }), buildPlace({ id: 22, name: 'Bouillon Pigalle' })],
|
||||
reservations: [buildReservation({ id: 31, title: 'Dinner at Le Train Bleu', day_id: 11 })],
|
||||
});
|
||||
}
|
||||
|
||||
async function input(): Promise<HTMLTextAreaElement> {
|
||||
render(<AssistantSurface tripId={TRIP} tripTitle="Paris" variant="window" />);
|
||||
return (await screen.findByRole('textbox', { name: 'Ask anything about Paris' })) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
const mentions = () => chatOf(useAssistantStore.getState(), TRIP).mentions;
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom lays nothing out and has no element scrolling; the viewport only needs the call to exist.
|
||||
Element.prototype.scrollTo = vi.fn() as unknown as typeof Element.prototype.scrollTo;
|
||||
});
|
||||
|
||||
beforeEach(() => send.mockClear());
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
useTripStore.setState(INITIAL_TRIP, true);
|
||||
});
|
||||
|
||||
describe('AssistantComposer mentions', () => {
|
||||
it('FE-AI-MENTION-001: typing after @ lists matches, and Enter picks one instead of sending', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
expect(screen.getByText('Type @ to mention days, places and bookings.')).toBeInTheDocument();
|
||||
fireEvent.change(field, { target: { value: 'Is @lou' } });
|
||||
|
||||
expect(await screen.findByRole('option', { name: 'Louvre' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'Bouillon Pigalle' })).toBeNull();
|
||||
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
await waitFor(() => expect(field.value).toBe('Is @Louvre '));
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
// A browser reports where the caret went after the pick; jsdom does not on its own.
|
||||
fireEvent.select(field);
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
|
||||
expect(mentions()).toEqual([{ kind: 'place', id: 21, label: 'Louvre' }]);
|
||||
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Is @Louvre ');
|
||||
// The pick shows in the text only, never as a chip above it.
|
||||
expect(screen.queryByRole('button', { name: 'Remove context' })).toBeNull();
|
||||
|
||||
fireEvent.change(field, { target: { value: 'Is @Louvre open late?' } });
|
||||
fireEvent.keyDown(field, { key: 'Enter' });
|
||||
await waitFor(() => expect(send).toHaveBeenCalledWith(TRIP, 'Is @Louvre open late?'));
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-002: a bare @ offers the kinds, and a kind opens its entries', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: '@' } });
|
||||
|
||||
expect(await screen.findByRole('option', { name: 'Days' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Places' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Bookings' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Days' }));
|
||||
expect(await screen.findByRole('option', { name: /Arrival/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Day 2' })).toBeInTheDocument();
|
||||
|
||||
// The heading leads back to the kinds.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Days' }));
|
||||
expect(await screen.findByRole('option', { name: 'Places' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Days' }));
|
||||
|
||||
fireEvent.click(await screen.findByRole('option', { name: /Arrival/ }));
|
||||
await waitFor(() => expect(field.value).toBe('@Arrival '));
|
||||
expect(mentions()).toEqual([{ kind: 'day', id: 11, label: 'Arrival' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-003: Escape closes the list and keeps what was typed', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: 'near @bou' } });
|
||||
expect(await screen.findByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(field, { key: 'Escape' });
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
|
||||
expect(field.value).toBe('near @bou');
|
||||
expect(mentions()).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-004: @ inside a word, such as an email address, opens nothing', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: 'mail@lou' } });
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-005: says so when nothing matches', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: '@zzz' } });
|
||||
expect(await screen.findByText('Nothing found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-006: a mention is taken back by deleting its @label, with no chip row to keep in step', async () => {
|
||||
seed();
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: 'Dinner after @Din' } });
|
||||
fireEvent.click(await screen.findByRole('option', { name: /Dinner at Le Train Bleu/ }));
|
||||
await waitFor(() => expect(field.value).toBe('Dinner after @Dinner at Le Train Bleu '));
|
||||
expect(screen.queryByRole('button', { name: 'Remove context' })).toBeNull();
|
||||
|
||||
fireEvent.change(field, { target: { value: 'Dinner after ' } });
|
||||
expect(chatOf(useAssistantStore.getState(), TRIP).draft).toBe('Dinner after ');
|
||||
expect(screen.queryByText('Dinner at Le Train Bleu')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-007: for a trip the planner does not show, @ stays plain text', async () => {
|
||||
seed(99);
|
||||
const field = await input();
|
||||
fireEvent.change(field, { target: { value: '@lou' } });
|
||||
expect(screen.queryByRole('listbox')).toBeNull();
|
||||
expect(screen.queryByText('Type @ to mention days, places and bookings.')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { AuiIf, ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react';
|
||||
import { ArrowUp, CalendarDays, MapPin, Paperclip, Square, X } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import PlaceAvatar from '../shared/PlaceAvatar';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import AssistantMentionPopover from './AssistantMentionPopover';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { useAssistantAttach } from './useAssistantAttach';
|
||||
import { useAssistantFocus } from './useAssistantFocus';
|
||||
import { useAssistantMentions } from './useAssistantMentions';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
import { useTypewriterPlaceholder } from './useTypewriterPlaceholder';
|
||||
|
||||
/** Send and Stop share one spot; on the phone it is a full thumb's size. */
|
||||
const ACTION = {
|
||||
window: 'h-9 w-9',
|
||||
fullscreen: 'h-11 w-11',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Where the user writes. One rounded shell with the text and, in the same
|
||||
* spot, Send or Stop. Enter sends, Shift+Enter breaks the line, Escape stops
|
||||
* a running answer. The one chip above the text is the focus the question is
|
||||
* about, and can be removed. `@` at the start of a word opens the trip's days,
|
||||
* places and bookings to name; a pick stays as `@label` in the text, which is
|
||||
* what the question names, and shows as a chip only in the sent bubble. While
|
||||
* that list is open, Enter picks and Escape closes it. While the field is empty,
|
||||
* its placeholder writes out things worth asking about this trip, one after
|
||||
* another.
|
||||
*
|
||||
* The text typed so far lives in the assistant store too, so it survives the
|
||||
* window closing or the phone hopping to the map and back.
|
||||
*/
|
||||
export default function AssistantComposer({ tripId, tripTitle }: { tripId: number; tripTitle: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const aui = useAui();
|
||||
const setDraft = useAssistantStore((s) => s.setDraft);
|
||||
const { label, kind, place, clear } = useAssistantFocus(tripId);
|
||||
const mentions = useAssistantMentions(tripId);
|
||||
const { suggestions } = useAssistantSuggestions(tripId);
|
||||
const empty = useAuiState((s) => s.composer.isEmpty);
|
||||
const { take, busy, accept } = useAssistantAttach(tripId);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
|
||||
const placeholder = useTypewriterPlaceholder(
|
||||
suggestions.map((s) => s.prompt),
|
||||
fallback,
|
||||
empty
|
||||
);
|
||||
|
||||
// Takes back what was typed before the chat was last shown.
|
||||
useEffect(() => {
|
||||
const draft = chatOf(useAssistantStore.getState(), tripId).draft;
|
||||
if (draft) aui.composer.setText(draft);
|
||||
}, [aui, tripId]);
|
||||
|
||||
const FocusIcon = kind === 'place' ? MapPin : CalendarDays;
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="relative rounded-2xl border border-edge-faint bg-surface-card shadow-card transition-colors focus-within:border-edge">
|
||||
<ComposerPrimitive.TriggerPopoverRoot>
|
||||
<AssistantMentionPopover adapter={mentions.adapter} onInserted={mentions.onInserted} />
|
||||
{label && (
|
||||
<div className="flex flex-wrap gap-1 px-2 pt-2">
|
||||
<ContextChip
|
||||
label={label}
|
||||
icon={place ? <PlaceAvatar place={place} size={18} /> : <FocusIcon size={12} aria-hidden="true" />}
|
||||
round={!!place}
|
||||
onRemove={clear}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 p-1.5">
|
||||
<Tooltip label={t('assistant.attach.button')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
aria-label={t('assistant.attach.button')}
|
||||
className={`flex ${ACTION[variant]} flex-shrink-0 items-center justify-center rounded-full text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40`}
|
||||
>
|
||||
<Paperclip size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept={accept}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
take(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<ComposerPrimitive.Input
|
||||
// The desktop window opens ready to type; on the phone the keyboard waits for a tap.
|
||||
autoFocus={variant === 'window'}
|
||||
dir="auto"
|
||||
minRows={1}
|
||||
maxRows={8}
|
||||
submitMode="enter"
|
||||
addAttachmentOnPaste={false}
|
||||
enterKeyHint="send"
|
||||
aria-label={fallback}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(tripId, e.target.value)}
|
||||
onPaste={(e) => {
|
||||
if (e.clipboardData.files.length > 0 && take(e.clipboardData.files)) e.preventDefault();
|
||||
}}
|
||||
className="max-h-48 min-h-[36px] flex-1 resize-none bg-transparent px-1.5 py-1.5 text-body text-content outline-none placeholder:text-content-faint disabled:cursor-not-allowed dark:bg-transparent"
|
||||
/>
|
||||
<AuiIf condition={(s) => s.thread.isRunning}>
|
||||
<ComposerPrimitive.Cancel
|
||||
aria-label={t('assistant.composer.stop')}
|
||||
className={`flex ${ACTION[variant]} flex-shrink-0 items-center justify-center rounded-full bg-accent text-accent-text transition-transform hover:scale-105 hover:bg-accent-hover active:scale-95`}
|
||||
>
|
||||
<Square size={12} fill="currentColor" aria-hidden="true" />
|
||||
</ComposerPrimitive.Cancel>
|
||||
</AuiIf>
|
||||
<AuiIf condition={(s) => !s.thread.isRunning}>
|
||||
<SendButton size={ACTION[variant]} />
|
||||
</AuiIf>
|
||||
</div>
|
||||
</ComposerPrimitive.TriggerPopoverRoot>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/** One thing the question is about, above the text, with the cross that takes it back. */
|
||||
function ContextChip({
|
||||
label,
|
||||
icon,
|
||||
round,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
/** A place's picture fills the chip's round end, so the chip starts closer to it. */
|
||||
round: boolean;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-full bg-surface-secondary py-0.5 pe-1 text-caption text-content-secondary ${round ? 'ps-0.5' : 'ps-2'}`}
|
||||
>
|
||||
<span className="flex flex-shrink-0">{icon}</span>
|
||||
<bdi className="truncate">{label}</bdi>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={t('assistant.context.remove')}
|
||||
className="rounded-full p-0.5 text-content-faint hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SendButton({ size }: { size: string }) {
|
||||
const { t } = useTranslation();
|
||||
// Sending waits while a proposed change is undecided; the tooltip says so.
|
||||
const blocked = useAuiState((s) => s.thread.isSendDisabled);
|
||||
const button = (
|
||||
<ComposerPrimitive.Send
|
||||
aria-label={t('assistant.composer.send')}
|
||||
className={`flex ${size} flex-shrink-0 items-center justify-center rounded-full bg-accent text-accent-text shadow-card transition-[transform,opacity,box-shadow] duration-150 hover:scale-105 hover:bg-accent-hover hover:shadow-dropdown active:scale-95 disabled:scale-100 disabled:opacity-30 disabled:shadow-none`}
|
||||
>
|
||||
<ArrowUp size={17} strokeWidth={2.4} aria-hidden="true" />
|
||||
</ComposerPrimitive.Send>
|
||||
);
|
||||
// A disabled button gets no pointer events, so the tooltip that says why hangs on a wrapper.
|
||||
return blocked ? (
|
||||
<Tooltip label={t('assistant.composer.decideFirst')} placement="top">
|
||||
<span className="inline-flex">{button}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
button
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { AssistantChangeEffect, AssistantMessage } from '@trek/shared';
|
||||
import { ArrowRightLeft, Minus, Sparkles } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { changeEffects } from '../../store/assistantMapModel';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
|
||||
/** What a waiting change does to one day, as a row of the preview. */
|
||||
interface PreviewRow {
|
||||
toolCallId: string;
|
||||
kind: 'add' | 'in' | 'remove' | 'out';
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** The changes no one has decided on yet, by their call. */
|
||||
function waitingCalls(messages: readonly AssistantMessage[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const m of messages) {
|
||||
for (const p of m.parts) {
|
||||
if (p.type === 'tool-call' && p.approval && !p.approval.resolution && p.approval.approved === undefined) {
|
||||
ids.add(p.toolCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function rowOf(
|
||||
toolCallId: string,
|
||||
effect: AssistantChangeEffect,
|
||||
dayId: number,
|
||||
nameOf: (id: number | null) => string | null
|
||||
) {
|
||||
const name = effect.place?.name ?? nameOf(effect.placeId);
|
||||
if (!name) return null;
|
||||
if (effect.dayId === dayId) {
|
||||
const kind = effect.kind === 'remove' ? 'remove' : effect.kind === 'move' ? 'in' : 'add';
|
||||
return { toolCallId, kind, name } satisfies PreviewRow;
|
||||
}
|
||||
if (effect.kind === 'move' && effect.fromDayId === dayId)
|
||||
return { toolCallId, kind: 'out', name } satisfies PreviewRow;
|
||||
return null;
|
||||
}
|
||||
|
||||
const LOOK: Record<PreviewRow['kind'], { icon: typeof Sparkles; row: string; name: string; badgeKey: string }> = {
|
||||
add: {
|
||||
icon: Sparkles,
|
||||
row: 'border-dashed border-accent bg-accent-subtle',
|
||||
name: 'text-content',
|
||||
badgeKey: 'assistant.preview.suggested',
|
||||
},
|
||||
in: {
|
||||
icon: ArrowRightLeft,
|
||||
row: 'border-dashed border-accent bg-accent-subtle',
|
||||
name: 'text-content',
|
||||
badgeKey: 'assistant.preview.movedHere',
|
||||
},
|
||||
remove: {
|
||||
icon: Minus,
|
||||
row: 'border-dashed border-edge',
|
||||
name: 'text-content-faint line-through',
|
||||
badgeKey: 'assistant.preview.removed',
|
||||
},
|
||||
out: {
|
||||
icon: ArrowRightLeft,
|
||||
row: 'border-dashed border-edge',
|
||||
name: 'text-content-faint line-through',
|
||||
badgeKey: 'assistant.preview.movedAway',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The assistant's waiting changes inside the day they touch, in the planner's
|
||||
* own day list: a place it would add as a dashed row, one it would take off
|
||||
* struck through, a move on both of its days. Resting on a row lights the
|
||||
* change's pin and card; a click brings the chat forward to decide. Nothing
|
||||
* here writes; the change card stays the one place a change is applied.
|
||||
*/
|
||||
export default function AssistantDayPreview({ tripId, dayId }: { tripId: number; dayId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
|
||||
const hovered = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null));
|
||||
const { setMapHover, open } = useAssistantStore(useShallow((s) => ({ setMapHover: s.setMapHover, open: s.open })));
|
||||
const places = useTripStore((s) => s.places);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const waiting = waitingCalls(messages);
|
||||
if (waiting.size === 0) return [];
|
||||
const nameOf = (id: number | null) => (id === null ? null : (places.find((p) => p.id === id)?.name ?? null));
|
||||
const out: PreviewRow[] = [];
|
||||
for (const [toolCallId, effect] of changeEffects(messages)) {
|
||||
if (!waiting.has(toolCallId)) continue;
|
||||
const row = rowOf(toolCallId, effect, dayId, nameOf);
|
||||
if (row) out.push(row);
|
||||
}
|
||||
return out;
|
||||
}, [messages, places, dayId]);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<ul className="m-0 flex list-none flex-col gap-1 px-3 pb-2 pt-1" aria-label={t('assistant.preview.title')}>
|
||||
{rows.map((row, i) => {
|
||||
const look = LOOK[row.kind];
|
||||
const Icon = look.icon;
|
||||
const lit = hovered === row.toolCallId;
|
||||
return (
|
||||
<li
|
||||
key={`${row.toolCallId}-${row.kind}`}
|
||||
className="trek-assistant-enter"
|
||||
style={{ animationDelay: `${i * 50}ms` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
onMouseEnter={() => setMapHover(tripId, row.toolCallId, 'card')}
|
||||
onMouseLeave={() => setMapHover(tripId, null, 'card')}
|
||||
className={`flex w-full items-center gap-2 rounded-xl border px-2.5 py-1.5 text-start transition-all ${look.row} ${
|
||||
lit ? 'shadow-md ring-2 ring-accent' : 'hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<span className="grid h-6 w-6 flex-shrink-0 place-items-center rounded-lg bg-surface-card text-content-muted shadow-sm">
|
||||
<Icon size={12} aria-hidden="true" />
|
||||
</span>
|
||||
<bdi className={`min-w-0 flex-1 truncate text-caption font-medium ${look.name}`}>{row.name}</bdi>
|
||||
<span className="flex-shrink-0 rounded-full bg-surface-card px-2 py-0.5 text-caption text-content-muted shadow-sm">
|
||||
{t(look.badgeKey)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* FE-AI-ERR-001 to FE-AI-ERR-008
|
||||
*
|
||||
* The error line: one sentence, at most one thing to do, and that thing
|
||||
* does what it says.
|
||||
*/
|
||||
import type { AiStatusResponse, AssistantErrorCode } from '@trek/shared';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { setForcedOffline } from '../../sync/networkMode';
|
||||
import AssistantError from './AssistantError';
|
||||
|
||||
const TRIP = 4;
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
|
||||
const STATUS: AiStatusResponse = {
|
||||
available: true,
|
||||
policy: 'instance_or_user',
|
||||
canConnectOwn: true,
|
||||
effective: { source: 'user', kind: 'anthropic', model: 'claude', host: 'api.anthropic.com', paidBy: 'user' },
|
||||
own: null,
|
||||
instance: null,
|
||||
quota: null,
|
||||
privacyAcked: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
openrouterOAuth: true,
|
||||
};
|
||||
|
||||
const retry = vi.fn(async () => {});
|
||||
const continueHere = vi.fn(async () => {});
|
||||
const send = vi.fn(async () => {});
|
||||
|
||||
function withError(error: AssistantErrorCode | null, status: AiStatusResponse = STATUS) {
|
||||
useAssistantStore.setState({
|
||||
status,
|
||||
retry,
|
||||
continueHere,
|
||||
send,
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), error } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderLine() {
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/trips/4" element={<AssistantError tripId={TRIP} />} />
|
||||
<Route path="/settings" element={<p>settings page</p>} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/trips/4'] }
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => setForcedOffline(false));
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
setForcedOffline(false);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AssistantError', () => {
|
||||
it('FE-AI-ERR-001: shows nothing while all is well', () => {
|
||||
withError(null);
|
||||
const { container } = renderLine();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-002: names the provider and retries a provider that was down', () => {
|
||||
withError('provider_unavailable');
|
||||
renderLine();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Anthropic isn’t responding right now.');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(retry).toHaveBeenCalledWith(TRIP);
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-003: a busy chat continues here, and a long run goes on with "Continue"', () => {
|
||||
withError('busy');
|
||||
const { unmount } = renderLine();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue here' }));
|
||||
expect(continueHere).toHaveBeenCalledWith(TRIP);
|
||||
unmount();
|
||||
|
||||
withError('max_steps');
|
||||
renderLine();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
expect(send).toHaveBeenCalledWith(TRIP, 'Continue');
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-004: an expired sign-in leads straight to the settings card', () => {
|
||||
withError('needs_reauth');
|
||||
renderLine();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in again' }));
|
||||
expect(screen.getByText('settings page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-006: an own key that stopped working says so before anything is sent', () => {
|
||||
withError(null, {
|
||||
...STATUS,
|
||||
own: {
|
||||
scope: 'user',
|
||||
kind: 'anthropic',
|
||||
model: 'claude',
|
||||
hint: '…a1b2',
|
||||
status: 'error',
|
||||
host: 'api.anthropic.com',
|
||||
},
|
||||
});
|
||||
renderLine();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Replace key' }));
|
||||
expect(screen.getByText('settings page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-007: a provider limit names the provider, without guessing at its plan', () => {
|
||||
withError('rate_limited');
|
||||
renderLine();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Limit reached at Anthropic. Try again later.');
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-008: waiting changes ask for a decision before a place can be suggested', () => {
|
||||
withError('decide_first');
|
||||
renderLine();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Decide on the open suggestions first.');
|
||||
});
|
||||
|
||||
it('FE-AI-ERR-005: offline wins over any other error and offers nothing to press', () => {
|
||||
withError('provider_unavailable');
|
||||
setForcedOffline(true);
|
||||
renderLine();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline. The assistant needs internet.');
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CircleAlert, WifiOff } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { type AssistantErrorActionKind, connectionErrorOf, errorView } from './assistantModel';
|
||||
import { AI_SETTINGS_PATH } from './assistantParts';
|
||||
import { CHATGPT_USAGE_URL, providerLabel } from './providerCatalog';
|
||||
|
||||
/**
|
||||
* Why the assistant cannot go on, as one line above the composer with at
|
||||
* most one thing to do about it: try again, continue, sign in again, and so
|
||||
* on. Offline needs no button; the line says why the composer is off.
|
||||
*/
|
||||
export default function AssistantError({ tripId }: { tripId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { offline } = useNetworkMode();
|
||||
const error = useAssistantStore((s) => chatOf(s, tripId).error);
|
||||
const status = useAssistantStore((s) => s.status);
|
||||
const retry = useAssistantStore((s) => s.retry);
|
||||
const continueHere = useAssistantStore((s) => s.continueHere);
|
||||
const send = useAssistantStore((s) => s.send);
|
||||
|
||||
const code = offline ? 'offline' : (error ?? connectionErrorOf(status));
|
||||
if (!code) return null;
|
||||
|
||||
const effective = status?.effective;
|
||||
const provider = effective ? providerLabel(effective.kind, effective.host) : '';
|
||||
const view = errorView(code, { canConnectOwn: status?.canConnectOwn ?? false, provider });
|
||||
|
||||
const run = (kind: AssistantErrorActionKind) => {
|
||||
if (kind === 'retry') void retry(tripId);
|
||||
else if (kind === 'continueHere') void continueHere(tripId);
|
||||
// The model asked whether to go on; the answer is the button's own word.
|
||||
else if (kind === 'continue') void send(tripId, t('assistant.errorAction.continue'));
|
||||
else if (kind === 'openSettings') void navigate(AI_SETTINGS_PATH);
|
||||
else window.open(CHATGPT_USAGE_URL, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const { action } = view;
|
||||
const Icon = code === 'offline' ? WifiOff : CircleAlert;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="trek-assistant-enter flex items-center gap-2.5 rounded-lg bg-surface-secondary px-3 py-2 text-caption text-content-secondary"
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" className="flex-shrink-0 text-warning" />
|
||||
<span className="min-w-0 flex-1">{t(view.messageKey, { provider })}</span>
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => run(action.kind)}
|
||||
className="flex-shrink-0 rounded-md px-2 py-1 text-caption font-medium text-accent-on hover:bg-surface-hover"
|
||||
>
|
||||
{t(action.labelKey)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CircleCheck, TriangleAlert } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { buildDayAfter, changedDays, type DayChange, type TimelineStop } from './dayAfterModel';
|
||||
|
||||
/** Lines the tooltip names; the rest are only counted on the pill. */
|
||||
const MAX_LINES = 3;
|
||||
|
||||
/** What does not fit on a stop of the day: arriving after the next thing starts, or a way too tight. */
|
||||
function problemOf(stop: TimelineStop, t: (key: string, params?: Record<string, string | number>) => string) {
|
||||
const lead = stop.lead;
|
||||
if (lead?.overlapsWith) return t('assistant.change.timeline.overlap', { place: lead.overlapsWith });
|
||||
if (lead?.shortBy != null) return `${stop.name}: ${t('assistant.change.timeline.tight', { count: lead.shortBy })}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the days a waiting card changes still work out in time, as one
|
||||
* small pill in the card's head: "fits" in green, or how many things to look
|
||||
* at in amber, with what they are on hover. The days themselves show the
|
||||
* suggestion in the planner's day list, so the card does not draw them again.
|
||||
* Shown only while the planner holds this trip, whose day plans it reads.
|
||||
*/
|
||||
export default function AssistantFitCheck({ changes }: { changes: readonly DayChange[] }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const plan = useTripStore(
|
||||
useShallow((s) => ({ here: s.trip?.id === tripId, days: s.days, assignments: s.assignments, places: s.places }))
|
||||
);
|
||||
|
||||
const check = useMemo(() => {
|
||||
if (!plan.here || changes.length === 0) return null;
|
||||
const stops = changedDays(changes).flatMap((dayId) => {
|
||||
const day = plan.days.find((d) => d.id === dayId);
|
||||
if (!day) return [];
|
||||
return buildDayAfter({
|
||||
dayId,
|
||||
assignments: plan.assignments,
|
||||
places: plan.places,
|
||||
changes,
|
||||
defaultMode: day.default_transport_mode,
|
||||
}).stops;
|
||||
});
|
||||
const measured = stops.some((stop) => stop.lead?.travelMinutes != null);
|
||||
const problems = stops.map((stop) => problemOf(stop, t)).filter((line): line is string => line !== null);
|
||||
return measured || problems.length > 0 ? problems : null;
|
||||
}, [plan, changes, t]);
|
||||
|
||||
if (!check) return null;
|
||||
const ok = check.length === 0;
|
||||
const pill = (
|
||||
<span
|
||||
className={`inline-flex h-6 flex-shrink-0 items-center gap-1 rounded-full px-2 text-caption font-medium ${
|
||||
ok ? 'bg-success-soft text-success' : 'bg-warning-soft text-warning'
|
||||
}`}
|
||||
>
|
||||
{ok ? <CircleCheck size={12} aria-hidden="true" /> : <TriangleAlert size={12} aria-hidden="true" />}
|
||||
{ok ? t('assistant.check.ok') : t('assistant.check.issues', { count: check.length })}
|
||||
</span>
|
||||
);
|
||||
if (ok) return pill;
|
||||
return (
|
||||
<Tooltip label={check.slice(0, MAX_LINES).join('\n')} placement="top">
|
||||
<span tabIndex={0} className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
|
||||
{pill}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { AssistantMessage } from '@trek/shared';
|
||||
import { CornerDownRight } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useCanDo } from '../../store/permissionsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { followUpsFor } from './assistantFollowUpRules';
|
||||
import { hasUndecided } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
|
||||
/** Each chip comes in a moment after the one before it. */
|
||||
const STAGGER_MS = 60;
|
||||
|
||||
/**
|
||||
* The next questions under the latest answer, one tap each. Only under the
|
||||
* last answer, once it is written, and not while changes wait for a decision:
|
||||
* then the cards are what to answer.
|
||||
*/
|
||||
export default function AssistantFollowUps({ message }: { message: AssistantMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const can = useCanDo();
|
||||
const trip = useTripStore((s) => (s.trip?.id === tripId ? s.trip : null));
|
||||
const send = useAssistantStore((s) => s.send);
|
||||
const shown = useAssistantStore((s) => {
|
||||
const chat = chatOf(s, tripId);
|
||||
const last = chat.messages[chat.messages.length - 1];
|
||||
return !chat.isRunning && !chat.error && last?.id === message.id && !hasUndecided(chat.messages);
|
||||
});
|
||||
if (!shown) return null;
|
||||
const chips = followUpsFor(message, !!trip && can('place_edit', trip));
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div role="group" aria-label={t('assistant.followUp.title')} className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
{chips.map((chip, i) => {
|
||||
const text = t(chip.labelKey);
|
||||
return (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
onClick={() => void send(tripId, text)}
|
||||
className="trek-assistant-enter group/chip inline-flex max-w-full items-center gap-1.5 rounded-full bg-surface-card px-3 py-1.5 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md hover:ring-edge active:translate-y-0"
|
||||
style={{ animationDelay: `${i * STAGGER_MS}ms` }}
|
||||
>
|
||||
<CornerDownRight
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 text-content-faint transition-colors group-hover/chip:text-content rtl:-scale-x-100"
|
||||
/>
|
||||
<span className="truncate">{text}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
|
||||
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { PopoverItem } from '../Packing/PackingPopover';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu';
|
||||
import { NEUTRAL_TINT } from '../shared/DialogShell';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import { PopoverListPanel } from '../shared/dialogParts';
|
||||
import { usePopoverList } from '../shared/usePopoverList';
|
||||
import AssistantMemoryDialog from './AssistantMemoryDialog';
|
||||
import { AI_SETTINGS_PATH } from './assistantParts';
|
||||
import { useAssistantHintsSetting } from './useAssistantHints';
|
||||
import { useAssistantLiveStep } from './useAssistantLiveStep';
|
||||
import type { useAssistantThread } from './useAssistantThread';
|
||||
import { useTripMemory } from './useTripMemory';
|
||||
|
||||
// The round, raised buttons of TREK's dialog heads.
|
||||
const ICON_BUTTON =
|
||||
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content';
|
||||
|
||||
interface AssistantHeaderProps {
|
||||
tripId: number;
|
||||
thread: ReturnType<typeof useAssistantThread>;
|
||||
tripTitle: string;
|
||||
onClose: () => void;
|
||||
/** Moves the window: spread on the title, which is the part of the header to grab. */
|
||||
dragProps: HTMLAttributes<HTMLElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The window's head, in the look of TREK's dialog heads: a tinted band with
|
||||
* a small line naming the assistant over the conversation's name; the band is where the window is
|
||||
* grabbed to move it. On the end side the recent chats (a new chat starts at
|
||||
* the top of that list), more and close, as round raised buttons. Closing
|
||||
* keeps the conversation, and an answer being written goes on. The model is not picked here;
|
||||
* that lives in the settings.
|
||||
*/
|
||||
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const live = useAssistantLiveStep(tripId);
|
||||
const memory = useTripMemory(tripId);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const menu = useContextMenu();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const threads = thread.threads ?? [];
|
||||
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
|
||||
const { open: historyOpen, triggerRef: historyTrigger, listId: historyListId } = history;
|
||||
const hints = useAssistantHintsSetting();
|
||||
|
||||
const openHistory = () => {
|
||||
if (!history.open) thread.refreshThreads();
|
||||
history.toggle();
|
||||
};
|
||||
|
||||
const pick = (run: () => void) => {
|
||||
history.close();
|
||||
run();
|
||||
};
|
||||
|
||||
const openMenu = (e: MouseEvent) =>
|
||||
menu.open(
|
||||
e,
|
||||
[
|
||||
{
|
||||
label: t('assistant.history.new'),
|
||||
icon: Plus,
|
||||
disabled: thread.threadId === null && !thread.isRunning,
|
||||
onClick: thread.newThread,
|
||||
},
|
||||
{
|
||||
label: t(hints.on ? 'assistant.hint.menuOff' : 'assistant.hint.menuOn'),
|
||||
icon: hints.on ? LightbulbOff : Lightbulb,
|
||||
onClick: hints.toggle,
|
||||
},
|
||||
{ label: t('assistant.window.settings'), icon: Settings, onClick: () => void navigate(AI_SETTINGS_PATH) },
|
||||
{
|
||||
label: t('assistant.window.deleteThread'),
|
||||
icon: Trash2,
|
||||
danger: true,
|
||||
disabled: thread.threadId === null || thread.isRunning,
|
||||
onClick: () => setConfirming(true),
|
||||
},
|
||||
],
|
||||
true
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex flex-shrink-0 items-center gap-2 border-b border-edge-faint py-2.5 pe-2.5 ps-4"
|
||||
style={{ background: NEUTRAL_TINT }}
|
||||
>
|
||||
<div
|
||||
{...dragProps}
|
||||
className="flex min-w-0 flex-1 cursor-grab touch-none select-none items-center active:cursor-grabbing"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{thread.isRunning ? (
|
||||
// While it works, the line over the title says what it does right now.
|
||||
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption font-medium">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="trek-assistant-pulse h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
|
||||
/>
|
||||
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="m-0 truncate font-geist text-caption font-bold uppercase tracking-[.08em] text-content-muted">
|
||||
{t('assistant.launcher.tooltip')}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="m-0 truncate text-body font-bold tracking-[-0.01em] text-content">
|
||||
<bdi>{thread.title ?? tripTitle}</bdi>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
{/* Buttons are no place to start a drag from. */}
|
||||
<div
|
||||
className="flex flex-shrink-0 items-center gap-1"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMemoryOpen(true)}
|
||||
aria-label={t('assistant.memory.title')}
|
||||
className={`relative ${ICON_BUTTON}`}
|
||||
>
|
||||
<NotebookPen size={13} aria-hidden="true" />
|
||||
{memory.has && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -end-0.5 -top-0.5 h-2 w-2 rounded-full bg-accent ring-2 ring-[color:var(--bg-elevated)]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('assistant.history.open')}>
|
||||
<button
|
||||
ref={historyTrigger}
|
||||
type="button"
|
||||
onClick={openHistory}
|
||||
aria-label={t('assistant.history.open')}
|
||||
aria-expanded={historyOpen}
|
||||
aria-controls={historyOpen ? historyListId : undefined}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<History size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('assistant.window.menu')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openMenu}
|
||||
aria-label={t('assistant.window.menu')}
|
||||
aria-haspopup="menu"
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<Ellipsis size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.close')}>
|
||||
<button type="button" onClick={onClose} aria-label={t('common.close')} className={ICON_BUTTON}>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<PopoverListPanel list={history} label={t('assistant.history.open')} className="w-72">
|
||||
<PopoverItem
|
||||
icon={<Plus size={14} />}
|
||||
label={t('assistant.history.new')}
|
||||
onClick={() => pick(thread.newThread)}
|
||||
/>
|
||||
{threads.length === 0 ? (
|
||||
<p className="m-0 px-2.5 py-2 text-caption text-content-muted">{t('assistant.history.empty')}</p>
|
||||
) : (
|
||||
threads.map((th) => (
|
||||
<PopoverItem
|
||||
key={th.id}
|
||||
icon={null}
|
||||
label={th.title}
|
||||
active={th.id === thread.threadId}
|
||||
onClick={() => pick(() => thread.openThread(th.id))}
|
||||
trailing={
|
||||
<span className="flex-shrink-0 text-caption text-content-faint">
|
||||
{new Date(th.lastMessageAt).toLocaleDateString(locale, { day: 'numeric', month: 'short' })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{thread.retentionNote && (
|
||||
<p className="m-0 border-t border-edge-faint px-2.5 pb-1 pt-2 text-caption text-content-faint">
|
||||
{thread.retentionNote}
|
||||
</p>
|
||||
)}
|
||||
</PopoverListPanel>
|
||||
|
||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
||||
<ConfirmDialog
|
||||
isOpen={confirming}
|
||||
onClose={() => setConfirming(false)}
|
||||
onConfirm={() => {
|
||||
setConfirming(false);
|
||||
void thread.removeThread();
|
||||
}}
|
||||
title={t('assistant.window.deleteThread')}
|
||||
message={t('assistant.window.deleteConfirm')}
|
||||
confirmLabel={t('common.delete')}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* FE-AI-HINTLIST-001 to FE-AI-HINTLIST-006
|
||||
*
|
||||
* The hint list in the empty chat: three rows at most with the rest behind
|
||||
* "n more", a row that asks, an X that waves one away, rows shown counted as
|
||||
* seen, and rows that wait while offline.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantHints from './AssistantHints';
|
||||
import type { AssistantHintsView, ReadyHint } from './useAssistantHints';
|
||||
|
||||
const network = vi.hoisted(() => ({ offline: false }));
|
||||
vi.mock('../../hooks/useNetworkMode', () => ({
|
||||
useNetworkMode: () => ({ offline: network.offline, forced: false, setForced: () => undefined }),
|
||||
}));
|
||||
|
||||
function hint(id: string, level: ReadyHint['level'] = 'info'): ReadyHint {
|
||||
const rule = level === 'warn' ? 'overlap' : 'gap';
|
||||
return { id, rule, level, text: `Line ${id}`, prompt: `Ask ${id}`, focus: null };
|
||||
}
|
||||
|
||||
function view(hints: ReadyHint[]): AssistantHintsView {
|
||||
return {
|
||||
hints,
|
||||
unseenWarn: 0,
|
||||
markSeen: vi.fn(),
|
||||
markWarnsSeen: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
ask: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
network.offline = false;
|
||||
});
|
||||
|
||||
describe('AssistantHints', () => {
|
||||
it('FE-AI-HINTLIST-001: shows nothing without hints, and three rows with the rest behind "n more"', () => {
|
||||
const { container, unmount } = render(<AssistantHints view={view([])} phone={false} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
unmount();
|
||||
|
||||
const five = view(['a', 'b', 'c', 'd', 'e'].map((id) => hint(id)));
|
||||
render(<AssistantHints view={five} phone={false} />);
|
||||
expect(screen.getByRole('region', { name: 'Worth a look' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
||||
expect(five.markSeen).toHaveBeenLastCalledWith(['a', 'b', 'c']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '2 more' }));
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(5);
|
||||
expect(five.markSeen).toHaveBeenLastCalledWith(['a', 'b', 'c', 'd', 'e']);
|
||||
});
|
||||
|
||||
it('FE-AI-HINTLIST-002: a row asks its question, and the X waves the hint away', () => {
|
||||
const one = view([hint('a', 'warn')]);
|
||||
render(<AssistantHints view={one} phone={false} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Line a' }));
|
||||
expect(one.ask).toHaveBeenCalledWith(one.hints[0]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide this hint' }));
|
||||
expect(one.dismiss).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
it('FE-AI-HINTLIST-003: on the phone the X is always there, with room for a thumb', () => {
|
||||
render(<AssistantHints view={view([hint('a')])} phone />);
|
||||
const dismiss = screen.getByRole('button', { name: 'Hide this hint' });
|
||||
expect(dismiss.className).toContain('h-10');
|
||||
expect(dismiss.className).not.toContain('opacity-0');
|
||||
});
|
||||
|
||||
it('FE-AI-HINTLIST-006: in the window the X hides until hover only on a screen that can hover', () => {
|
||||
render(<AssistantHints view={view([hint('a')])} phone={false} />);
|
||||
const dismiss = screen.getByRole('button', { name: 'Hide this hint' });
|
||||
expect(dismiss.className).toContain('[@media(hover:hover)]:opacity-0');
|
||||
expect(dismiss.className.split(' ')).not.toContain('opacity-0');
|
||||
});
|
||||
|
||||
it('FE-AI-HINTLIST-004: offline the rows stay readable but do not ask', () => {
|
||||
network.offline = true;
|
||||
const one = view([hint('a')]);
|
||||
render(<AssistantHints view={one} phone={false} />);
|
||||
const row = screen.getByRole('button', { name: 'Line a' });
|
||||
expect(row).toBeDisabled();
|
||||
fireEvent.click(row);
|
||||
expect(one.ask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { BedDouble, Clock, type LucideIcon, MapPin, Ticket, TriangleAlert, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import type { HintRule } from './assistantHintRules';
|
||||
import type { AssistantHintsView, ReadyHint } from './useAssistantHints';
|
||||
|
||||
/** Rows shown before "n more", so the suggestions under them stay in view. */
|
||||
export const MAX_VISIBLE_HINTS = 3;
|
||||
|
||||
/** Warnings all carry the one triangle; a note says what it is about. */
|
||||
const NOTE_ICONS: Partial<Record<HintRule, LucideIcon>> = {
|
||||
gap: Clock,
|
||||
longLeg: MapPin,
|
||||
noStay: BedDouble,
|
||||
unplacedBooking: Ticket,
|
||||
};
|
||||
|
||||
/**
|
||||
* The plan hints in the empty chat: a short, quiet list, at most three rows
|
||||
* with the rest behind "n more". A row is the button: it puts its day or
|
||||
* places in focus and asks its question. Each can be waved away. On the
|
||||
* desktop the X shows on hover or focus, on the phone always, with room for
|
||||
* a thumb. Shown means seen, so the launcher stops pointing at them.
|
||||
*/
|
||||
export default function AssistantHints({ view, phone }: { view: AssistantHintsView; phone: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { offline } = useNetworkMode();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const shown = expanded ? view.hints : view.hints.slice(0, MAX_VISIBLE_HINTS);
|
||||
const rest = view.hints.length - shown.length;
|
||||
const shownIds = shown.map((h) => h.id).join('\n');
|
||||
const { markSeen } = view;
|
||||
|
||||
useEffect(() => {
|
||||
if (shownIds) markSeen(shownIds.split('\n'));
|
||||
}, [shownIds, markSeen]);
|
||||
|
||||
if (view.hints.length === 0) return null;
|
||||
return (
|
||||
<section aria-label={t('assistant.hint.title')} className="flex w-full max-w-sm flex-col gap-1.5">
|
||||
<h3 className="m-0 px-1 text-caption font-medium text-content-muted">{t('assistant.hint.title')}</h3>
|
||||
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
{shown.map((hint) => (
|
||||
<HintRow key={hint.id} hint={hint} phone={phone} disabled={offline} view={view} />
|
||||
))}
|
||||
</ul>
|
||||
{rest > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className={`self-start rounded-lg px-1 text-caption text-content-muted hover:text-content ${phone ? 'min-h-[44px]' : 'py-1'}`}
|
||||
>
|
||||
{t('assistant.hint.more', { count: rest })}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HintRow({
|
||||
hint,
|
||||
phone,
|
||||
disabled,
|
||||
view,
|
||||
}: {
|
||||
hint: ReadyHint;
|
||||
phone: boolean;
|
||||
disabled: boolean;
|
||||
view: AssistantHintsView;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const warn = hint.level === 'warn';
|
||||
const Icon = warn ? TriangleAlert : (NOTE_ICONS[hint.rule] ?? Clock);
|
||||
const dismissLabel = t('assistant.hint.dismiss');
|
||||
return (
|
||||
<li className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => view.ask(hint)}
|
||||
className={`flex w-full items-start gap-2 rounded-xl border border-edge-faint bg-surface-card py-2 ps-3 text-start text-body text-content transition-colors hover:border-edge hover:bg-surface-hover disabled:cursor-default disabled:opacity-60 disabled:hover:border-edge-faint disabled:hover:bg-surface-card ${
|
||||
phone ? 'min-h-[44px] pe-11' : 'pe-9'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={`mt-[3px] flex-shrink-0 ${warn ? 'text-content-secondary' : 'text-content-muted'}`}
|
||||
/>
|
||||
<span dir="auto" className="line-clamp-2 min-w-0 flex-1">
|
||||
{hint.text}
|
||||
</span>
|
||||
</button>
|
||||
{/* Hidden until hover only where there is hover: a touch screen at window size shows it always. */}
|
||||
<Tooltip label={dismissLabel}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => view.dismiss(hint.id)}
|
||||
aria-label={dismissLabel}
|
||||
className={`absolute grid place-items-center rounded-lg text-content-faint transition-opacity hover:bg-surface-hover hover:text-content ${
|
||||
phone
|
||||
? 'end-0.5 top-0.5 h-10 w-10'
|
||||
: 'end-1 top-1 h-7 w-7 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:hover)]:opacity-0'
|
||||
}`}
|
||||
>
|
||||
<X size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* FE-AI-HOST-001 to FE-AI-HOST-005
|
||||
*
|
||||
* The desktop host follows the trip on screen and sends "Show on map" and a
|
||||
* picked day to the planner through its links.
|
||||
*/
|
||||
import type { AssistantApplied } from '@trek/shared';
|
||||
import { useLocation } from 'react-router';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { act, render, screen } from '../../../tests/helpers/render';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Trip } from '../../types';
|
||||
import AssistantHost from './AssistantHost';
|
||||
|
||||
interface WindowProps {
|
||||
trip: { id: number; title: string } | null;
|
||||
onShowOnMap?: (applied: AssistantApplied) => void;
|
||||
onFocusDay?: (dayId: number) => void;
|
||||
}
|
||||
const seen: { props: WindowProps | null } = { props: null };
|
||||
|
||||
vi.mock('./AssistantWindow', () => ({
|
||||
default: (props: WindowProps) => {
|
||||
seen.props = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
function Location() {
|
||||
const { pathname, search } = useLocation();
|
||||
return <output data-testid="location">{`${pathname}${search}`}</output>;
|
||||
}
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<>
|
||||
<AssistantHost />
|
||||
<Location />
|
||||
</>,
|
||||
{ initialEntries: [path] }
|
||||
);
|
||||
}
|
||||
|
||||
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
|
||||
toolCallId: 'c1',
|
||||
entity,
|
||||
ids,
|
||||
dayId,
|
||||
undoUntil: null,
|
||||
});
|
||||
|
||||
const INITIAL = useTripStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
useTripStore.setState(INITIAL, true);
|
||||
seen.props = null;
|
||||
});
|
||||
|
||||
describe('AssistantHost', () => {
|
||||
it('FE-AI-HOST-001: outside a trip the window gets no trip and asks which one', () => {
|
||||
renderAt('/dashboard');
|
||||
expect(seen.props?.trip).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-HOST-002: inside a trip it is about that trip, named once the planner has it', () => {
|
||||
renderAt('/trips/5/files');
|
||||
expect(seen.props?.trip).toEqual({ id: 5, title: '' });
|
||||
|
||||
act(() => useTripStore.setState({ trip: { id: 5, title: 'Kyoto' } as Trip }));
|
||||
expect(seen.props?.trip).toEqual({ id: 5, title: 'Kyoto' });
|
||||
});
|
||||
|
||||
it('FE-AI-HOST-003: "Show on map" opens the planner on the place, or on the day', () => {
|
||||
renderAt('/trips/5/files');
|
||||
act(() => seen.props?.onShowOnMap?.(applied('place', [7], 2)));
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?place=7');
|
||||
|
||||
act(() => seen.props?.onShowOnMap?.(applied('day_note', [9], 3)));
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=3');
|
||||
});
|
||||
|
||||
it('FE-AI-HOST-005: a day picked in the chat selects it in the planner', () => {
|
||||
renderAt('/trips/5');
|
||||
act(() => seen.props?.onFocusDay?.(12));
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=12');
|
||||
});
|
||||
|
||||
it('FE-AI-HOST-004: a route that only looks like a trip is none', () => {
|
||||
renderAt('/trips/new');
|
||||
expect(seen.props?.trip).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { AssistantApplied } from '@trek/shared';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation, useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantWindow from './AssistantWindow';
|
||||
import { showOnMapSearch } from './assistantShowOnMap';
|
||||
|
||||
/** The trip a route under /trips/:id shows, or null anywhere else. */
|
||||
function useRouteTripId(): number | null {
|
||||
const match = useMatch('/trips/:id/*');
|
||||
const id = Number(match?.params.id);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop assistant, mounted once above the routes so a conversation
|
||||
* keeps going from page to page. Inside a trip it is about that trip; "Show
|
||||
* on map" opens the planner on the change through its links (`?place=`,
|
||||
* `?day=`), the same way a notification opens a place, and a day picked in
|
||||
* the chat (its weather) selects that day the same way.
|
||||
*/
|
||||
export default function AssistantHost() {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const tripId = useRouteTripId();
|
||||
// The title is there once the planner has loaded the trip; the window waits for nothing else.
|
||||
const title = useTripStore((s) => (s.trip && s.trip.id === tripId ? s.trip.title : ''));
|
||||
const trip = useMemo(() => (tripId === null ? null : { id: tripId, title }), [tripId, title]);
|
||||
|
||||
const openPlanner = useCallback(
|
||||
(search: string) => {
|
||||
if (tripId === null) return;
|
||||
const planner = `/trips/${tripId}`;
|
||||
void navigate({ pathname: planner, search }, { replace: pathname === planner });
|
||||
},
|
||||
[navigate, pathname, tripId]
|
||||
);
|
||||
const showOnMap = useCallback((applied: AssistantApplied) => openPlanner(showOnMapSearch(applied)), [openPlanner]);
|
||||
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
|
||||
|
||||
return <AssistantWindow trip={trip} onShowOnMap={showOnMap} onFocusDay={focusDay} />;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* FE-AI-LAUNCH-001 to FE-AI-LAUNCH-006
|
||||
*
|
||||
* The way in: hidden until the assistant can run, a button that opens and
|
||||
* closes the window, a dot for warnings in the plan not seen yet, and
|
||||
* ⌘K / Ctrl+K, which leaves a dialog alone.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantLauncher from './AssistantLauncher';
|
||||
import { isAssistantHotkey, useAssistantHotkey } from './useAssistantHotkey';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
|
||||
const STATUS = { available: true } as AiStatusResponse;
|
||||
|
||||
function press(init: KeyboardEventInit): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
window.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
afterEach(() => useAssistantStore.setState(INITIAL, true));
|
||||
|
||||
describe('AssistantLauncher', () => {
|
||||
it('FE-AI-LAUNCH-001: shows nothing until the assistant has a connection to use', () => {
|
||||
useAssistantStore.setState({ status: { ...STATUS, available: false } });
|
||||
const { container } = render(<AssistantLauncher variant="navbar" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-AI-LAUNCH-002: the navbar button and the floating one open and close the window', () => {
|
||||
useAssistantStore.setState({ status: STATUS });
|
||||
const { unmount } = render(<AssistantLauncher variant="navbar" />);
|
||||
const button = screen.getByRole('button', { name: 'Assistant' });
|
||||
fireEvent.click(button);
|
||||
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||
expect(button).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(button);
|
||||
expect(useAssistantStore.getState().window.open).toBe(false);
|
||||
unmount();
|
||||
|
||||
render(<AssistantLauncher variant="floating" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-AI-LAUNCH-006: the floating button carries a dot for a warning not seen yet, until the chat is opened', () => {
|
||||
window.localStorage.clear();
|
||||
resetAllStores();
|
||||
const places = [
|
||||
buildPlace({ id: 50, place_time: '10:00', end_time: '12:00' }),
|
||||
buildPlace({ id: 51, place_time: '11:00', end_time: '12:30' }),
|
||||
];
|
||||
const stops = places.map((place, i) =>
|
||||
buildAssignment({ id: 60 + i, day_id: 1, place_id: place.id, order_index: i, place })
|
||||
);
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: 3, start_date: '2099-06-01', end_date: '2099-06-01' }),
|
||||
days: [buildDay({ id: 1, trip_id: 3, date: '2099-06-01' })],
|
||||
places,
|
||||
assignments: { '1': stops },
|
||||
reservations: [],
|
||||
dayNotes: {},
|
||||
});
|
||||
useAssistantStore.setState({ status: STATUS });
|
||||
|
||||
const { unmount } = render(<AssistantLauncher variant="navbar" />);
|
||||
expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<AssistantLauncher variant="floating" tripId={3} />);
|
||||
const button = screen.getByRole('button', { name: 'Assistant, 1 hint' });
|
||||
// The dot stands out from the accent button it sits on.
|
||||
expect(button.querySelector('.bg-warning')).not.toBeNull();
|
||||
fireEvent.click(button);
|
||||
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||
expect(screen.getByRole('button', { name: 'Assistant' }).querySelector('.bg-warning')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shortcut', () => {
|
||||
it('FE-AI-LAUNCH-003: is ⌘K or Ctrl+K, without Alt or Shift', () => {
|
||||
const key = (init: KeyboardEventInit) => isAssistantHotkey(new KeyboardEvent('keydown', init));
|
||||
expect(key({ key: 'k', metaKey: true })).toBe(true);
|
||||
expect(key({ key: 'K', ctrlKey: true })).toBe(true);
|
||||
expect(key({ key: 'k' })).toBe(false);
|
||||
expect(key({ key: 'k', ctrlKey: true, altKey: true })).toBe(false);
|
||||
expect(key({ key: 'k', metaKey: true, shiftKey: true })).toBe(false);
|
||||
expect(key({ key: 'j', metaKey: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-LAUNCH-004: toggles and takes the key, only while enabled', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = renderHook(({ enabled }) => useAssistantHotkey(enabled, onToggle), {
|
||||
initialProps: { enabled: false },
|
||||
});
|
||||
expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(false);
|
||||
expect(onToggle).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ enabled: true });
|
||||
expect(press({ key: 'k', ctrlKey: true }).defaultPrevented).toBe(true);
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-LAUNCH-005: leaves the key alone while a dialog is open or something else took it', () => {
|
||||
const onToggle = vi.fn();
|
||||
renderHook(() => useAssistantHotkey(true, onToggle));
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'trek-modal-backdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
press({ key: 'k', metaKey: true });
|
||||
backdrop.remove();
|
||||
|
||||
const taken = new KeyboardEvent('keydown', { key: 'k', metaKey: true, cancelable: true });
|
||||
taken.preventDefault();
|
||||
window.dispatchEvent(taken);
|
||||
|
||||
expect(onToggle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import { useAssistantHints } from './useAssistantHints';
|
||||
import { useAssistantShortcutKeys } from './useAssistantHotkey';
|
||||
|
||||
/** Where the floating button sits, from the window's end and bottom edges. The window grows out of it. */
|
||||
export const LAUNCHER_INSET = 24;
|
||||
export const LAUNCHER_SIZE = 48;
|
||||
|
||||
/**
|
||||
* The way into the assistant: a button in the navbar, and in the planner a
|
||||
* round one floating at the bottom end of the screen. Both open and close the
|
||||
* window; outside a trip it opens on the trip picker. Nothing shows until the
|
||||
* assistant has a connection this user can use: no setup prompt in its place.
|
||||
* The floating one carries a dot while the trip has a warning the user has
|
||||
* not been shown; opening the chat counts them as seen.
|
||||
*/
|
||||
export default function AssistantLauncher({
|
||||
variant,
|
||||
tripId = null,
|
||||
focusOnMount = false,
|
||||
}: {
|
||||
variant: 'navbar' | 'floating';
|
||||
/** The trip the floating button stands in, for its hint dot. */
|
||||
tripId?: number | null;
|
||||
/** Takes the focus when it appears, as when the window it stands for was just closed. */
|
||||
focusOnMount?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open && !s.window.minimized);
|
||||
const toggle = useAssistantStore((s) => s.toggle);
|
||||
// An answer still being written in a closed window: the floating button breathes until it is opened.
|
||||
const busy = useAssistantStore((s) =>
|
||||
!s.window.open || s.window.minimized ? Object.values(s.byTrip).some((c) => c?.isRunning) : false
|
||||
);
|
||||
const keys = useAssistantShortcutKeys();
|
||||
const hints = useAssistantHints(variant === 'floating' ? tripId : null, { withStays: false });
|
||||
const floating = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (focusOnMount) floating.current?.focus();
|
||||
}, [focusOnMount]);
|
||||
if (!available) return null;
|
||||
|
||||
const label = t('assistant.launcher.tooltip');
|
||||
if (variant === 'navbar') {
|
||||
return (
|
||||
<Tooltip label={label} shortcut={keys}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={label}
|
||||
aria-pressed={open}
|
||||
className={`relative hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg p-2 transition-colors sm:flex ${
|
||||
open ? 'bg-surface-selected text-content' : 'text-content-muted hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const pointed = hints.unseenWarn > 0 ? t('assistant.launcher.withHints', { count: hints.unseenWarn }) : label;
|
||||
const openChat = () => {
|
||||
if (!open) hints.markWarnsSeen();
|
||||
toggle();
|
||||
};
|
||||
return (
|
||||
<Tooltip label={pointed} shortcut={keys} placement="top">
|
||||
<button
|
||||
ref={floating}
|
||||
type="button"
|
||||
onClick={openChat}
|
||||
aria-label={pointed}
|
||||
aria-busy={busy || undefined}
|
||||
className="trek-assistant-pop fixed z-[var(--z-nav)] flex items-center justify-center rounded-full bg-accent text-accent-text shadow-elevated transition-transform hover:scale-105 active:scale-95"
|
||||
style={{
|
||||
insetInlineEnd: LAUNCHER_INSET,
|
||||
bottom: LAUNCHER_INSET,
|
||||
width: LAUNCHER_SIZE,
|
||||
height: LAUNCHER_SIZE,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
{busy && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="trek-assistant-busy absolute inset-0 rounded-full border-2 border-current opacity-40"
|
||||
/>
|
||||
)}
|
||||
<Sparkles size={20} aria-hidden="true" />
|
||||
{hints.unseenWarn > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute end-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-warning ring-2 ring-[var(--bg-elevated)]"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* FE-AI-LIVE-001 to FE-AI-LIVE-004
|
||||
*
|
||||
* The running step's line: what it does, and once the stream says where it
|
||||
* looks, the place or the query, with a pin when the map can show the area.
|
||||
*/
|
||||
import type { AssistantActivity, AssistantMessage } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import { AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
const TRIP = 4;
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
|
||||
const running: AssistantMessage = {
|
||||
id: 'msg-2',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-21T08:00:00.000Z',
|
||||
parts: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'search_nearby_places', args: {} }],
|
||||
};
|
||||
|
||||
function seed(activity: AssistantActivity | null) {
|
||||
useAssistantStore.setState({
|
||||
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), messages: [running], live: { activity } } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderSteps() {
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={{ tripId: TRIP, variant: 'window' }}>
|
||||
<AssistantToolSteps steps={[{ toolCallId: 'call-1', toolName: 'search_nearby_places', running: true }]} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => useAssistantStore.setState(INITIAL, true));
|
||||
|
||||
describe('AssistantLiveStep', () => {
|
||||
it('FE-AI-LIVE-001: names the place the running step searches near', () => {
|
||||
seed({
|
||||
toolCallId: 'call-1',
|
||||
label: 'Trastevere',
|
||||
area: { type: 'point', lat: 41.88, lng: 12.47, radiusM: 800 },
|
||||
});
|
||||
const { container } = renderSteps();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching near Trastevere');
|
||||
expect(container.querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-LIVE-002: without a place it keeps the step name, and no pin', () => {
|
||||
seed({ toolCallId: 'call-1', label: null, area: null });
|
||||
const { container } = renderSteps();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching nearby');
|
||||
expect(container.querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it("FE-AI-LIVE-003: a place another step named is not this step's", () => {
|
||||
seed({ toolCallId: 'call-0', label: 'Trastevere', area: null });
|
||||
renderSteps();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching nearby');
|
||||
});
|
||||
|
||||
it('FE-AI-LIVE-004: with "map follows the assistant" off the place stays, the pin goes', () => {
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
useSettingsStore.setState({ settings: { ...settings, assistant_map_follow: false } });
|
||||
seed({
|
||||
toolCallId: 'call-1',
|
||||
label: 'Trastevere',
|
||||
area: { type: 'point', lat: 41.88, lng: 12.47, radiusM: 800 },
|
||||
});
|
||||
const { container } = renderSteps();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Searching near Trastevere');
|
||||
expect(container.querySelector('svg')).toBeNull();
|
||||
useSettingsStore.setState({ settings });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MapPin } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { toolStepKey } from './assistantModel';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { useAssistantLiveStep } from './useAssistantLiveStep';
|
||||
|
||||
/**
|
||||
* The running step as one quiet line: what it does, shimmering, and once it
|
||||
* names a place, where ("Searching near Trastevere"). A small pin says the
|
||||
* map is looking there too, shown only while the map follows the assistant.
|
||||
*/
|
||||
export default function AssistantLiveStep({ toolCallId, toolName }: { toolCallId: string; toolName: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const live = useAssistantLiveStep(tripId);
|
||||
const here = live?.toolCallId === toolCallId ? live : null;
|
||||
return (
|
||||
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption">
|
||||
{here?.hasArea && <MapPin size={12} aria-hidden="true" className="flex-shrink-0 text-content-faint" />}
|
||||
<span className="trek-assistant-shimmer min-w-0 truncate">{here ? here.text : t(toolStepKey(toolName))}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* FE-AI-MD-001 to FE-AI-MD-005
|
||||
*
|
||||
* An answer is untrusted text: nothing in it may load by itself, raw HTML
|
||||
* never becomes markup, and a link always shows where it goes.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantMarkdown from './AssistantMarkdown';
|
||||
|
||||
describe('AssistantMarkdown', () => {
|
||||
it('FE-AI-MD-001: renders Markdown as text, lists and emphasis', () => {
|
||||
const { container } = render(<AssistantMarkdown text={'**Kyoto** first\n\n- Kinkaku-ji\n- Ryoan-ji'} />);
|
||||
expect(container.querySelector('strong')).toHaveTextContent('Kyoto');
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-AI-MD-002: draws no image, in Markdown or as raw HTML', () => {
|
||||
const { container } = render(
|
||||
<AssistantMarkdown text={'\n\n<img src="https://evil.example/q.gif">'} />
|
||||
);
|
||||
expect(container.querySelector('img')).toBeNull();
|
||||
expect(container.querySelector('picture, video, audio, iframe')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-MD-003: leaves no javascript: link to click', () => {
|
||||
const { container } = render(<AssistantMarkdown text={'[open](javascript:alert(1))'} />);
|
||||
const link = container.querySelector('a');
|
||||
expect(link?.getAttribute('href') ?? '').not.toMatch(/^javascript:/i);
|
||||
});
|
||||
|
||||
it('FE-AI-MD-004: an outside link opens in its own tab and names its host', () => {
|
||||
render(<AssistantMarkdown text={'[Tickets](https://www.kinkaku-ji.or.jp/en/)'} />);
|
||||
const link = screen.getByRole('link', { name: /Tickets/ });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||
expect(link).toHaveTextContent('(www.kinkaku-ji.or.jp)');
|
||||
});
|
||||
|
||||
it('FE-AI-MD-005: a link into TREK stays in the app, and the writing caret is marked', () => {
|
||||
const { container } = render(<AssistantMarkdown text={'See [day 2](/trips/4)'} writing />);
|
||||
const link = screen.getByRole('link', { name: 'day 2' });
|
||||
expect(link).toHaveAttribute('href', '/trips/4');
|
||||
expect(link).not.toHaveAttribute('target');
|
||||
expect(container.firstElementChild).toHaveAttribute('data-writing', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { memo } from 'react';
|
||||
import Markdown, { type Components } from 'react-markdown';
|
||||
import { Link } from 'react-router';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { sanitizedMarkdownPlugins } from '../shared/markdownSanitize';
|
||||
|
||||
/**
|
||||
* An answer's text, as Markdown. The model writes it, and what the model reads
|
||||
* (trip notes, place descriptions, search results) can carry someone else's
|
||||
* words, so it renders like untrusted input: raw HTML is skipped and
|
||||
* sanitized, and nothing that loads on its own (an image is a tracking pixel
|
||||
* that leaks a request to any host) is drawn at all.
|
||||
*
|
||||
* A link shows where it goes: an outside one names its host beside the text
|
||||
* and opens in its own tab, a link into TREK stays in the app.
|
||||
*/
|
||||
|
||||
const DISALLOWED = ['img', 'picture', 'source', 'video', 'audio', 'iframe', 'object', 'embed'];
|
||||
|
||||
function hostOf(href: string): string | null {
|
||||
try {
|
||||
return new URL(href).host || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const components: Components = {
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith('/') && !href.startsWith('//')) return <Link to={href}>{children}</Link>;
|
||||
const host = href ? hostOf(href) : null;
|
||||
const text = typeof children === 'string' ? children : null;
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">
|
||||
{children}
|
||||
{host && text !== host && text !== href && <span className="text-content-muted"> ({host})</span>}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function AssistantMarkdown({ text, writing = false }: { text: string; writing?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
dir="auto"
|
||||
data-writing={writing || undefined}
|
||||
className="trek-assistant-md break-words text-body text-content"
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={sanitizedMarkdownPlugins}
|
||||
disallowedElements={DISALLOWED}
|
||||
skipHtml
|
||||
components={components}
|
||||
>
|
||||
{text}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// An answer re-renders with every streamed word; a block whose text did not change keeps its parse.
|
||||
export default memo(AssistantMarkdown);
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NotebookPen, Plus } from 'lucide-react';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
} from '../shared/DialogShell';
|
||||
import { TEXTAREA } from '../shared/dialogParts';
|
||||
import { MAX_MEMORY_CHARS, useTripMemory } from './useTripMemory';
|
||||
|
||||
/** What travellers most often want minded, one tap to add each. */
|
||||
const QUICK = ['vegetarian', 'kids', 'budget', 'slow', 'accessible', 'noMuseums'] as const;
|
||||
|
||||
/**
|
||||
* The trip's notes for the assistant: a few lines it follows in every
|
||||
* suggestion on this trip, written once instead of in every question. The
|
||||
* common ones are a tap away; the rest is free text.
|
||||
*/
|
||||
export default function AssistantMemoryDialog({ tripId, onClose }: { tripId: number; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const memory = useTripMemory(tripId);
|
||||
const [draft, setDraft] = useState(memory.text);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const add = (line: string) =>
|
||||
setDraft((prev) => (prev.includes(line) ? prev : `${prev.trimEnd()}${prev.trim() ? '\n' : ''}${line}`));
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
const done = await memory.save(draft);
|
||||
setSaving(false);
|
||||
if (done) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
discardGuard={draft}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<NotebookPen size={20} strokeWidth={2} />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
eyebrow={t('assistant.launcher.tooltip')}
|
||||
title={t('assistant.memory.title')}
|
||||
sub={t('assistant.memory.sub')}
|
||||
subWraps
|
||||
onClose={onClose}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<span className="text-caption tabular-nums text-content-faint">
|
||||
{draft.length}/{MAX_MEMORY_CHARS}
|
||||
</span>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||
<DialogButton variant="primary" disabled={saving} onClick={() => void save()}>
|
||||
{t(saving ? 'common.saving' : 'common.save')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<DialogSection label={t('assistant.memory.quick')}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{QUICK.map((key, i) => {
|
||||
const line = t(`assistant.memory.quick.${key}`);
|
||||
const taken = draft.includes(line);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={taken}
|
||||
onClick={() => add(line)}
|
||||
className="trek-assistant-enter inline-flex items-center gap-1 rounded-full bg-surface-card px-2.5 py-1 text-caption font-medium text-content-secondary shadow-sm ring-1 ring-edge-faint transition-all hover:-translate-y-px hover:text-content hover:shadow-md disabled:translate-y-0 disabled:opacity-40 disabled:shadow-none"
|
||||
style={{ animationDelay: `${i * 40}ms` }}
|
||||
>
|
||||
<Plus size={11} aria-hidden="true" />
|
||||
{line}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogSection>
|
||||
<DialogSection label={t('assistant.memory.notes')}>
|
||||
<textarea
|
||||
aria-label={t('assistant.memory.notes')}
|
||||
rows={6}
|
||||
maxLength={MAX_MEMORY_CHARS}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={t('assistant.memory.placeholder')}
|
||||
className={TEXTAREA}
|
||||
/>
|
||||
</DialogSection>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
ComposerPrimitive,
|
||||
type TriggerAdapter,
|
||||
type TriggerItem,
|
||||
unstable_useTriggerPopoverScopeContext as useTriggerScope,
|
||||
} from '@assistant-ui/react';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, MapPin, Ticket } from 'lucide-react';
|
||||
import { type MouseEvent, useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { MENTION_FORMATTER, MENTION_KINDS, type MentionKind } from './assistantMentionModel';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
|
||||
/** The icon of a day, place or booking in the `@` list. */
|
||||
function MentionKindIcon({ kind, size }: { kind: string; size: number }) {
|
||||
const props = { size, 'aria-hidden': true, className: 'flex-shrink-0' } as const;
|
||||
if (kind === 'day') return <CalendarDays {...props} />;
|
||||
if (kind === 'reservation') return <Ticket {...props} />;
|
||||
return <MapPin {...props} />;
|
||||
}
|
||||
|
||||
/** A row takes the click without the focus, so the caret stays in the text and typing goes on. */
|
||||
const keepFocus = (e: MouseEvent) => e.preventDefault();
|
||||
|
||||
/**
|
||||
* The list `@` opens over the composer: first the kinds (days, places,
|
||||
* bookings), and once letters follow the `@`, the entries that match across
|
||||
* all of them. The focus never leaves the text; arrows walk the list, Enter
|
||||
* or Tab picks, Escape closes it and keeps what was typed.
|
||||
*/
|
||||
export default function AssistantMentionPopover({
|
||||
adapter,
|
||||
onInserted,
|
||||
}: {
|
||||
/** Undefined while the trip's lists are not at hand; `@` then opens nothing. */
|
||||
adapter: TriggerAdapter | undefined;
|
||||
onInserted: (item: TriggerItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const phone = variant === 'fullscreen';
|
||||
return (
|
||||
<ComposerPrimitive.TriggerPopover
|
||||
char="@"
|
||||
adapter={adapter}
|
||||
aria-label={t('assistant.mention.hint')}
|
||||
className={`absolute bottom-full end-0 start-0 z-30 mb-1.5 overflow-y-auto rounded-xl border border-edge-faint bg-surface-card p-1 shadow-dropdown ${phone ? 'max-h-[40vh]' : 'max-h-56'}`}
|
||||
>
|
||||
<ComposerPrimitive.TriggerPopover.Directive formatter={MENTION_FORMATTER} onInserted={onInserted} />
|
||||
<MentionList row={phone ? 'min-h-[44px]' : 'min-h-[32px]'} />
|
||||
</ComposerPrimitive.TriggerPopover>
|
||||
);
|
||||
}
|
||||
|
||||
const ROW =
|
||||
'flex w-full items-center gap-2 rounded-lg px-2 text-start text-body text-content transition-colors hover:bg-surface-hover data-[highlighted]:bg-surface-hover';
|
||||
|
||||
function MentionList({ row }: { row: string }) {
|
||||
const { t } = useTranslation();
|
||||
const scope = useTriggerScope();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The highlighted row stays in view while the arrows walk past the list's edge.
|
||||
useEffect(() => {
|
||||
const hot = listRef.current?.querySelector<HTMLElement>('[data-highlighted]');
|
||||
hot?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [scope.highlightedIndex, scope.items, scope.activeCategoryId]);
|
||||
|
||||
if (!scope.open) return null;
|
||||
const listing = scope.isSearchMode || scope.activeCategoryId !== null;
|
||||
const active = scope.activeCategoryId ? MENTION_KINDS[scope.activeCategoryId as MentionKind] : undefined;
|
||||
const heading = active ? t(active.labelKey) : null;
|
||||
|
||||
return (
|
||||
<div ref={listRef}>
|
||||
{heading && (
|
||||
<ComposerPrimitive.TriggerPopoverBack
|
||||
onMouseDown={keepFocus}
|
||||
className="flex w-full items-center gap-1 rounded-lg px-1.5 py-1 text-caption font-semibold text-content-muted hover:text-content"
|
||||
>
|
||||
<ChevronLeft size={13} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
{heading}
|
||||
</ComposerPrimitive.TriggerPopoverBack>
|
||||
)}
|
||||
<ComposerPrimitive.TriggerPopoverCategories aria-label={t('assistant.mention.hint')}>
|
||||
{(categories) =>
|
||||
categories.map((c) => (
|
||||
<ComposerPrimitive.TriggerPopoverCategoryItem
|
||||
key={c.id}
|
||||
categoryId={c.id}
|
||||
onMouseDown={keepFocus}
|
||||
className={`${ROW} ${row}`}
|
||||
>
|
||||
<span className="flex text-content-muted">
|
||||
<MentionKindIcon kind={c.id} size={14} />
|
||||
</span>
|
||||
<span className="flex-1 truncate">{c.label}</span>
|
||||
<ChevronRight
|
||||
size={13}
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 text-content-faint rtl:-scale-x-100"
|
||||
/>
|
||||
</ComposerPrimitive.TriggerPopoverCategoryItem>
|
||||
))
|
||||
}
|
||||
</ComposerPrimitive.TriggerPopoverCategories>
|
||||
<ComposerPrimitive.TriggerPopoverItems aria-label={heading ?? t('assistant.mention.hint')}>
|
||||
{(items) =>
|
||||
items.map((item, index) => (
|
||||
<ComposerPrimitive.TriggerPopoverItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onMouseDown={keepFocus}
|
||||
className={`${ROW} ${row}`}
|
||||
>
|
||||
<span className="flex text-content-muted">
|
||||
<MentionKindIcon kind={item.type} size={14} />
|
||||
</span>
|
||||
<bdi className="min-w-0 flex-1 truncate">{item.label}</bdi>
|
||||
{item.description && (
|
||||
<span className="flex-shrink-0 text-caption text-content-faint">{item.description}</span>
|
||||
)}
|
||||
</ComposerPrimitive.TriggerPopoverItem>
|
||||
))
|
||||
}
|
||||
</ComposerPrimitive.TriggerPopoverItems>
|
||||
{listing && scope.items.length === 0 && (
|
||||
<p className="m-0 px-2 py-1.5 text-caption text-content-muted">{t('assistant.mention.none')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* FE-AI-MENTION-TEXT-001 to FE-AI-MENTION-TEXT-002
|
||||
*
|
||||
* A sent question with the days, places and bookings it named set off as
|
||||
* chips, and every other `@` left as text.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantMentionText from './AssistantMentionText';
|
||||
|
||||
describe('AssistantMentionText', () => {
|
||||
it('FE-AI-MENTION-TEXT-001: sets off each named @label and keeps the words around it', () => {
|
||||
const { container } = render(
|
||||
<p>
|
||||
<AssistantMentionText
|
||||
text="Is @Louvre open on @Arrival?"
|
||||
items={[
|
||||
{ kind: 'place', id: 21, label: 'Louvre' },
|
||||
{ kind: 'day', id: 11, label: 'Arrival' },
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
expect(container.querySelector('p')?.textContent).toBe('Is @Louvre open on @Arrival?');
|
||||
expect(screen.getByText('@Louvre').tagName).toBe('BDI');
|
||||
expect(screen.getByText('@Arrival').tagName).toBe('BDI');
|
||||
});
|
||||
|
||||
it('FE-AI-MENTION-TEXT-002: an @ that names nothing stays plain text', () => {
|
||||
const { container } = render(
|
||||
<p>
|
||||
<AssistantMentionText
|
||||
text="Write to me @ home or @Louvre2"
|
||||
items={[{ kind: 'place', id: 21, label: 'Louvre' }]}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
expect(container.querySelector('p')?.textContent).toBe('Write to me @ home or @Louvre2');
|
||||
expect(container.querySelector('bdi')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { AssistantMentions } from '@trek/shared';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { splitMentions } from './assistantMentionModel';
|
||||
|
||||
/**
|
||||
* A sent question as the user wrote it, with every `@label` it named a day,
|
||||
* place or booking with set off as a quiet inline chip. The labels come from
|
||||
* the question's own mentions part, so a stray `@` in the text stays text.
|
||||
*/
|
||||
export default function AssistantMentionText({ text, items }: { text: string; items: AssistantMentions['items'] }) {
|
||||
const segments = useMemo(
|
||||
() =>
|
||||
splitMentions(
|
||||
text,
|
||||
items.map((i) => i.label)
|
||||
),
|
||||
[text, items]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{segments.map((s) =>
|
||||
s.mention ? (
|
||||
<bdi key={s.at} className="rounded-md bg-surface-card px-1 font-medium text-content">
|
||||
{s.text}
|
||||
</bdi>
|
||||
) : (
|
||||
<span key={s.at}>{s.text}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react';
|
||||
import type { AssistantMessage as StoredMessage } from '@trek/shared';
|
||||
import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantChangeCard from './AssistantChangeCard';
|
||||
import AssistantFollowUps from './AssistantFollowUps';
|
||||
import AssistantMarkdown from './AssistantMarkdown';
|
||||
import AssistantMentionText from './AssistantMentionText';
|
||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||
import AssistantShareDialog from './AssistantShareDialog';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { useAssistantShare } from './useAssistantShare';
|
||||
|
||||
/**
|
||||
* One message of the chat. The user's question sits on the end side in a
|
||||
* quiet accent bubble; the answer runs as plain text across the column, with
|
||||
* its looked-up steps, the places and weather they found, and the proposed
|
||||
* changes in between, and a copy and a share button that show on hover
|
||||
* (always on the latest answer, and always on the phone, which has no hover).
|
||||
*/
|
||||
|
||||
const BAR_BUTTON =
|
||||
'rounded-md p-1 text-content-faint hover:bg-surface-hover hover:text-content-secondary disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent';
|
||||
|
||||
/** Posts the answer to the trip chat after a preview; a tick once it went. */
|
||||
function ShareButton({ share }: { share: ReturnType<typeof useAssistantShare> }) {
|
||||
const { t } = useTranslation();
|
||||
if (share.shared) {
|
||||
return (
|
||||
<Tooltip label={t('assistant.share.shared')}>
|
||||
<span className="inline-flex p-1 text-content-faint">
|
||||
<Check size={14} aria-hidden="true" />
|
||||
<span className="sr-only">{t('assistant.share.shared')}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const label = share.offline ? t('assistant.error.offline') : t('assistant.share.button');
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
{/* The wrapper carries the tooltip while the button is off. */}
|
||||
<span className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={share.open}
|
||||
disabled={share.offline || share.sending}
|
||||
aria-label={t('assistant.share.button')}
|
||||
className={BAR_BUTTON}
|
||||
>
|
||||
{share.sending ? (
|
||||
<LoaderCircle size={14} aria-hidden="true" className="animate-spin" />
|
||||
) : (
|
||||
<Share2 size={14} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A file the user handed over, as a chip with what it is and how big. */
|
||||
function AttachmentChip({ name, size }: { name: string; size: number }) {
|
||||
const kb = Math.max(1, Math.round(size / 1024));
|
||||
return (
|
||||
<span className="inline-flex max-w-full items-center gap-2.5 rounded-xl border border-edge-faint bg-surface-card py-2 pe-3.5 ps-2 shadow-card">
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-content-secondary">
|
||||
<FileText size={16} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<bdi className="truncate text-body font-medium text-content">{name}</bdi>
|
||||
<span className="text-caption tabular-nums text-content-muted">
|
||||
{kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb} KB`}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Question({ message }: { message: StoredMessage }) {
|
||||
const text = message.parts.map((p) => (p.type === 'text' ? p.text : '')).join('');
|
||||
const files = message.parts.flatMap((p) => (p.type === 'attachment' ? [p] : []));
|
||||
const mentions = message.parts.flatMap((p) => (p.type === 'data-trek-mentions' ? p.data.items : []));
|
||||
return (
|
||||
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col items-end gap-1.5 py-1.5">
|
||||
{files.map((f, i) => (
|
||||
<AttachmentChip key={`${f.name}-${i}`} name={f.name} size={f.size} />
|
||||
))}
|
||||
{text && (
|
||||
<p
|
||||
dir="auto"
|
||||
className="m-0 max-w-[85%] whitespace-pre-wrap break-words rounded-xl bg-accent-subtle px-3.5 py-2 text-body text-content"
|
||||
>
|
||||
{mentions.length > 0 ? <AssistantMentionText text={text} items={mentions} /> : text}
|
||||
</p>
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Answer({ message }: { message: StoredMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
const writing = useAssistantStore((s) => {
|
||||
const chat = chatOf(s, tripId);
|
||||
return chat.isRunning && chat.messages[chat.messages.length - 1]?.id === message.id;
|
||||
});
|
||||
const blocks = useMemo(() => messageBlocks(message, writing), [message, writing]);
|
||||
const share = useAssistantShare(tripId, message, writing);
|
||||
const last = blocks[blocks.length - 1];
|
||||
const hasText = blocks.some((b) => b.kind === 'text');
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
|
||||
{blocks.map((block) => {
|
||||
if (block.kind === 'text') {
|
||||
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
|
||||
}
|
||||
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
|
||||
if (block.kind === 'places') {
|
||||
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||
}
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||
})}
|
||||
{/* Nothing below says the answer goes on: no words being written, no step running. */}
|
||||
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
|
||||
<p role="status" className="m-0 text-caption">
|
||||
<span className="trek-assistant-shimmer">{t('assistant.step.working')}</span>
|
||||
</p>
|
||||
)}
|
||||
{hasText && !writing && (
|
||||
// The bar mounts on hover for older answers; the row keeps its height so nothing jumps.
|
||||
<div className="-mt-1 h-6">
|
||||
<ActionBarPrimitive.Root autohide={variant === 'fullscreen' ? 'never' : 'not-last'} className="-ms-1 flex">
|
||||
<ActionBarPrimitive.Copy aria-label={t('common.copy')} className={BAR_BUTTON}>
|
||||
<AuiIf condition={(s) => s.message.isCopied}>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</AuiIf>
|
||||
<AuiIf condition={(s) => !s.message.isCopied}>
|
||||
<Copy size={14} aria-hidden="true" />
|
||||
</AuiIf>
|
||||
</ActionBarPrimitive.Copy>
|
||||
{share.canShare && <ShareButton share={share} />}
|
||||
</ActionBarPrimitive.Root>
|
||||
</div>
|
||||
)}
|
||||
{share.canShare && <AssistantShareDialog share={share} />}
|
||||
<AssistantFollowUps message={message} />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AssistantMessage({ message }: { message: StoredMessage }) {
|
||||
return message.role === 'user' ? <Question message={message} /> : <Answer message={message} />;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { AssistantFoundPlace } from '@trek/shared';
|
||||
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { createElement, type ReactNode, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { getCategoryIcon } from '../shared/categoryIcons';
|
||||
import { PopoverListPanel } from '../shared/dialogParts';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { usePopoverList } from '../shared/usePopoverList';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
||||
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
|
||||
|
||||
type Strip = ReturnType<typeof useAssistantPlaceStrip>;
|
||||
|
||||
const ACTION =
|
||||
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
|
||||
|
||||
/** "+ Day 3" for the day in focus, a list of the trip's days beside it; "Pick a day" when no day is in focus. */
|
||||
function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { days, focusDay, disabled, blockedKey, adding } = strip;
|
||||
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
|
||||
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
|
||||
});
|
||||
const { open, toggle, close, triggerRef, listId } = list;
|
||||
const height = tall ? 'h-10' : 'h-7';
|
||||
const busy = adding === place.ref;
|
||||
|
||||
const pick = (dayId: number) => {
|
||||
close();
|
||||
void strip.suggest(place, dayId);
|
||||
};
|
||||
|
||||
const buttons = (
|
||||
<span className="inline-flex min-w-0 gap-px">
|
||||
{focusDay ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void strip.suggest(place, focusDay.id)}
|
||||
aria-label={t('assistant.place.addTo', { day: focusDay.label })}
|
||||
className={`${ACTION} ${height} rounded-e-none px-2`}
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle size={12} aria-hidden="true" className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
)}
|
||||
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
|
||||
</button>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
aria-label={t('assistant.place.pickDay')}
|
||||
className={`${ACTION} ${height} rounded-s-none px-1.5`}
|
||||
>
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
className={`${ACTION} ${height} px-2`}
|
||||
>
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
<span className="truncate">{t('assistant.place.pickDay')}</span>
|
||||
<ChevronDown size={12} aria-hidden="true" className="text-content-faint" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{blockedKey ? (
|
||||
<Tooltip label={t(blockedKey)} placement="top">
|
||||
{/* A disabled button gets no pointer events; the wrapper carries the reason. */}
|
||||
<span className="inline-flex min-w-0">{buttons}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
buttons
|
||||
)}
|
||||
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
|
||||
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day) => {
|
||||
const current = day.id === focusDay?.id;
|
||||
return (
|
||||
<Tooltip key={day.id} label={day.label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(day.id)}
|
||||
aria-label={day.label}
|
||||
aria-pressed={current}
|
||||
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="text-body font-semibold tabular-nums">{day.n}</span>
|
||||
{day.weekday && (
|
||||
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
|
||||
{day.weekday}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverListPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Category = ReturnType<Strip['categoryOf']>;
|
||||
|
||||
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
|
||||
function PlaceMedia({
|
||||
place,
|
||||
photo,
|
||||
category,
|
||||
}: {
|
||||
place: AssistantFoundPlace;
|
||||
photo: string | null;
|
||||
category: Category;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const icon = category ? getCategoryIcon(category.icon) : MapPin;
|
||||
return (
|
||||
<span
|
||||
className="relative block h-24 w-full overflow-hidden bg-surface-secondary"
|
||||
// Without a picture the category's own colour washes the space, data from the trip.
|
||||
style={
|
||||
photo || !category
|
||||
? undefined
|
||||
: { background: `color-mix(in srgb, ${category.color} 16%, var(--bg-secondary))` }
|
||||
}
|
||||
>
|
||||
{photo ? (
|
||||
<img
|
||||
src={photo}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover/card:scale-[1.04]"
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-full w-full place-items-center">
|
||||
{createElement(icon, {
|
||||
size: 30,
|
||||
'aria-hidden': true,
|
||||
style: { color: category?.color ?? 'var(--text-faint)' },
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{category && photo && (
|
||||
<span className="absolute start-2 top-2 inline-flex max-w-[75%] items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content shadow-sm">
|
||||
{createElement(icon, {
|
||||
size: 11,
|
||||
'aria-hidden': true,
|
||||
className: 'flex-shrink-0',
|
||||
style: { color: category.color },
|
||||
})}
|
||||
<bdi className="truncate">{category.name}</bdi>
|
||||
</span>
|
||||
)}
|
||||
{place.openNow !== null && (
|
||||
<span className="absolute bottom-2 end-2 inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 text-caption font-medium text-content-muted shadow-sm">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-1.5 w-1.5 rounded-full ${place.openNow ? 'bg-success' : 'bg-[color:var(--text-faint)]'}`}
|
||||
/>
|
||||
{t(place.openNow ? 'assistant.place.openNow' : 'assistant.place.closed')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's one way on: "On map" for a place the trip has, "Suggested" once it is, else "+ Day 3". */
|
||||
function PlaceAction({
|
||||
place,
|
||||
strip,
|
||||
suggested,
|
||||
tall,
|
||||
}: {
|
||||
place: AssistantFoundPlace;
|
||||
strip: Strip;
|
||||
suggested: boolean;
|
||||
tall: boolean;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
if (place.inTripPlaceId !== null) {
|
||||
return (
|
||||
<Tooltip label={t('assistant.place.inTrip')} placement="top">
|
||||
<button type="button" onClick={() => strip.show(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
|
||||
<MapPin size={12} aria-hidden="true" />
|
||||
{t('assistant.place.showOnMap')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (suggested) {
|
||||
return (
|
||||
<span className="inline-flex h-7 items-center gap-1 text-caption text-content-muted">
|
||||
<Check size={12} aria-hidden="true" className="text-success" />
|
||||
{t('assistant.place.suggested')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One place a search found, as a card: its picture from TREK's photo proxy
|
||||
* with the category it most likely belongs in on it (or that category's icon
|
||||
* on its colour), its name, address and rating, whether it is open now while
|
||||
* the trip runs, how far it is from the last stop of the day in focus, and one
|
||||
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
|
||||
* the card shows it on the map, and resting the pointer on it pins it there
|
||||
* for a moment.
|
||||
*/
|
||||
export default function AssistantPlaceCard({
|
||||
place,
|
||||
strip,
|
||||
suggested,
|
||||
}: {
|
||||
place: AssistantFoundPlace;
|
||||
strip: Strip;
|
||||
suggested: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { variant, density } = useAssistantView();
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
const photo = useFoundPlacePhoto(place, ref);
|
||||
const category = strip.categoryOf(place);
|
||||
const distance = strip.distanceOf(place);
|
||||
const tall = variant === 'fullscreen';
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
className={`group/card flex flex-shrink-0 snap-start flex-col overflow-hidden rounded-2xl border border-edge-faint bg-surface-card shadow-sm transition-shadow hover:shadow-md ${
|
||||
density === 'compact' ? 'w-[188px]' : 'w-[220px]'
|
||||
}`}
|
||||
onMouseEnter={() => strip.hover(place)}
|
||||
onMouseLeave={() => strip.hover(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => strip.show(place)}
|
||||
onFocus={() => strip.hover(place)}
|
||||
onBlur={() => strip.hover(null)}
|
||||
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
|
||||
>
|
||||
<PlaceMedia place={place} photo={photo} category={category} />
|
||||
<span className="flex min-w-0 flex-col gap-1 px-3 pt-2.5">
|
||||
<bdi className="line-clamp-2 text-body font-semibold leading-snug text-content">{place.name}</bdi>
|
||||
{category && !photo && <bdi className="truncate text-caption text-content-muted">{category.name}</bdi>}
|
||||
{place.address && <bdi className="truncate text-caption text-content-muted">{place.address}</bdi>}
|
||||
{(place.rating !== null || distance) && (
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-content-faint">
|
||||
{place.rating !== null && (
|
||||
<span className="inline-flex flex-shrink-0 items-center gap-0.5 font-medium text-content-muted">
|
||||
<Star size={11} aria-hidden="true" className="fill-current text-warning" />
|
||||
{place.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{distance && <span className="truncate">{t('assistant.place.fromPrev', { distance })}</span>}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
|
||||
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* FE-AI-PLACES-001 to FE-AI-PLACES-008
|
||||
*
|
||||
* The strip of places a search found: what a card says, that its picture only
|
||||
* ever comes from TREK's photo proxy, "+ Day N" for the day in focus or a
|
||||
* picked one, why it waits while a change is undecided, and the map's part.
|
||||
*/
|
||||
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAssignment,
|
||||
buildCategory,
|
||||
buildDay,
|
||||
buildPlace,
|
||||
buildTrip,
|
||||
buildUser,
|
||||
} from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { fetchPhoto } from '../../services/photoService';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { setForcedOffline } from '../../sync/networkMode';
|
||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
fetchPhoto: vi.fn(),
|
||||
getCached: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
const TRIP = 4;
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
const TRIPS = useTripStore.getState();
|
||||
const AUTH = useAuthStore.getState();
|
||||
const RealObserver = globalThis.IntersectionObserver;
|
||||
|
||||
const DAY_ONE = buildDay({ id: 31, date: '2026-11-02' });
|
||||
const DAY_TWO = buildDay({ id: 32, date: '2026-11-03' });
|
||||
|
||||
function place(ref: string, extra: Partial<AssistantFoundPlace> = {}): AssistantFoundPlace {
|
||||
return {
|
||||
ref,
|
||||
name: `Trattoria ${ref}`,
|
||||
address: null,
|
||||
lat: 41.889,
|
||||
lng: 12.47,
|
||||
kind: 'food',
|
||||
rating: null,
|
||||
openNow: null,
|
||||
googlePlaceId: null,
|
||||
osmId: null,
|
||||
photoKey: null,
|
||||
inTripPlaceId: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const answer = (parts: AssistantMessage['parts'] = []): AssistantMessage => ({
|
||||
id: 'msg-5',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-21T08:00:00.000Z',
|
||||
parts,
|
||||
});
|
||||
|
||||
const addFoundPlace = vi.fn(async () => {});
|
||||
const requestCamera = vi.fn();
|
||||
const setPreviewPin = vi.fn();
|
||||
const onRevealMap = vi.fn();
|
||||
|
||||
function seed({ focusDayId = 31, messages = [] as AssistantMessage[] } = {}) {
|
||||
useAuthStore.setState({ user: buildUser({ id: 1 }), placesPhotosEnabled: true });
|
||||
useTripStore.setState({
|
||||
trip: buildTrip({ id: TRIP, user_id: 1 }),
|
||||
days: [DAY_ONE, DAY_TWO],
|
||||
assignments: {
|
||||
'31': [buildAssignment({ order_index: 0, place: buildPlace({ lat: 41.9, lng: 12.47 }) })],
|
||||
},
|
||||
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
|
||||
});
|
||||
useAssistantStore.setState({
|
||||
addFoundPlace,
|
||||
requestCamera,
|
||||
setPreviewPin,
|
||||
byTrip: {
|
||||
[TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7, focus: { dayId: focusDayId }, messages },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderStrip(found: AssistantPlaces, message = answer(), view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: TRIP, variant: 'window', density: 'compact', onRevealMap, ...view };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={value}>
|
||||
<AssistantPlaceStrip found={found} message={message} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => setForcedOffline(false));
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
useTripStore.setState(TRIPS, true);
|
||||
useAuthStore.setState(AUTH, true);
|
||||
globalThis.IntersectionObserver = RealObserver;
|
||||
setForcedOffline(false);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AssistantPlaceStrip', () => {
|
||||
it('FE-AI-PLACES-001: a card names the place, its likely category, whether it is open and how far it is', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0', { openNow: true })], more: 0 });
|
||||
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
|
||||
expect(screen.getByText('Restaurant')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open now')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.2 km from the previous stop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-002: shows every card it was sent and counts what the server left out on a tile', () => {
|
||||
seed();
|
||||
const list = Array.from({ length: 8 }, (_, i) => place(`s1:${i}`));
|
||||
renderStrip({ toolCallId: 's1', places: list, more: 4 });
|
||||
expect(screen.getAllByRole('button', { name: /^Add to/ })).toHaveLength(8);
|
||||
expect(screen.getByText('4 more')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-009: a place known only by its position shows its category, never a photo of the area', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0', { photoKey: 'coords:41.889000,12.470000' })], more: 0 });
|
||||
expect(fetchPhoto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-003: a picture only ever comes from the photo proxy', async () => {
|
||||
seed();
|
||||
globalThis.IntersectionObserver = class {
|
||||
private readonly callback: IntersectionObserverCallback;
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
observe() {
|
||||
this.callback([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
|
||||
}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
vi.mocked(fetchPhoto).mockImplementation((key, _id, _lat, _lng, _name, callback) => {
|
||||
const photoUrl = key === 'good' ? '/api/maps/place-photo/good/bytes' : 'https://evil.example/x.jpg';
|
||||
callback?.({ photoUrl, thumbDataUrl: null });
|
||||
});
|
||||
const { container } = renderStrip({
|
||||
toolCallId: 's1',
|
||||
places: [place('s1:0', { photoKey: 'good' }), place('s1:1', { photoKey: 'bad', lat: 40 })],
|
||||
more: 0,
|
||||
});
|
||||
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1));
|
||||
expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/maps/place-photo/good/bytes');
|
||||
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-004: "+ Day 1" suggests the place for the day in focus', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add to/ }));
|
||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 31);
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-005: another day is picked from the list, and without a focus the list is the only way', () => {
|
||||
seed({ focusDayId: 0 });
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Pick a day/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Nov 3/ }));
|
||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 32);
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-006: waits while a change is undecided, and offline', () => {
|
||||
const waiting = answer([
|
||||
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
|
||||
]);
|
||||
seed({ messages: [waiting] });
|
||||
const { unmount } = renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
seed();
|
||||
setForcedOffline(true);
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
|
||||
seed();
|
||||
const suggested = answer([
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'f1',
|
||||
toolName: 'add_found_place',
|
||||
args: { lat: 41.889, lng: 12.47 },
|
||||
approval: { id: 'f1', approved: true },
|
||||
},
|
||||
]);
|
||||
renderStrip(
|
||||
{ toolCallId: 's1', places: [place('s1:0'), place('s1:1', { lat: 40, inTripPlaceId: 8 })], more: 0 },
|
||||
suggested,
|
||||
{ variant: 'fullscreen' }
|
||||
);
|
||||
expect(screen.getByText('Suggested')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'On map' }));
|
||||
expect(onRevealMap).toHaveBeenCalled();
|
||||
expect(requestCamera).toHaveBeenCalledWith(TRIP, [[40, 12.47]], 'pan', 'user');
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-008: resting on a card pins it on the map until the pointer leaves', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
const card = screen.getByText('Trattoria s1:0').closest('li');
|
||||
if (!card) throw new Error('no card');
|
||||
fireEvent.mouseEnter(card);
|
||||
expect(setPreviewPin).toHaveBeenLastCalledWith({
|
||||
tripId: TRIP,
|
||||
ref: 's1:0',
|
||||
lat: 41.889,
|
||||
lng: 12.47,
|
||||
name: 'Trattoria s1:0',
|
||||
});
|
||||
fireEvent.mouseLeave(card);
|
||||
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { AssistantMessage, AssistantPlaces } from '@trek/shared';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import AssistantPlaceCard from './AssistantPlaceCard';
|
||||
import { suggestedFromStrip } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
||||
|
||||
/** Cards the strip shows, every one the server sends; what it left out is counted on the tile at its end. */
|
||||
const SHOWN = 60;
|
||||
|
||||
// In the strip's head, never over a card: a press that ends a pixel off still lands on the arrow.
|
||||
const ARROW =
|
||||
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content disabled:cursor-default disabled:opacity-40 disabled:hover:text-content-secondary';
|
||||
|
||||
/** How far the strip has scrolled from either end, with the reading direction in mind. */
|
||||
function edgesOf(el: HTMLElement): { start: boolean; end: boolean } {
|
||||
// A right-to-left strip scrolls into negative numbers; the distance from the start is the same either way.
|
||||
const from = Math.abs(el.scrollLeft);
|
||||
return { start: from > 4, end: from + el.clientWidth < el.scrollWidth - 4 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The places a search found, as one row of cards to swipe or scroll through:
|
||||
* the next card peeks in, so the row says it goes on, and the edges fade
|
||||
* where there is more. On the desktop a head over the row names it and holds
|
||||
* two arrows that move it by whole cards, off while that way is the end. Every card the step found, then a
|
||||
* tile counting what the server left out.
|
||||
*/
|
||||
export default function AssistantPlaceStrip({ found, message }: { found: AssistantPlaces; message: AssistantMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const strip = useAssistantPlaceStrip();
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const [fade, setFade] = useState({ start: false, end: false });
|
||||
const rest = Math.max(0, found.places.length - SHOWN) + found.more;
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
const next = edgesOf(el);
|
||||
setFade((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
const el = listRef.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [measure, found]);
|
||||
|
||||
// The pin a card left on the map goes with the strip.
|
||||
const { hover } = strip;
|
||||
useEffect(() => () => hover(null), [hover]);
|
||||
|
||||
/**
|
||||
* Scrolls by as many whole cards as the view holds, towards the start (-1)
|
||||
* or the end (1), so the row lands on a card edge instead of fighting its
|
||||
* snap points half way; smoothly, unless motion is turned down.
|
||||
*/
|
||||
const page = (towards: -1 | 1) => {
|
||||
const el = listRef.current;
|
||||
const card = el?.firstElementChild as HTMLElement | null;
|
||||
if (!el || !card) return;
|
||||
const gap = Number.parseFloat(getComputedStyle(el).columnGap) || 0;
|
||||
const step = card.offsetWidth + gap;
|
||||
const cards = Math.max(1, Math.floor((el.clientWidth - 32) / step));
|
||||
const rtl = getComputedStyle(el).direction === 'rtl';
|
||||
// Smooth through the strip's own scroll-behavior, which reduced motion turns off.
|
||||
el.scrollBy((rtl ? -towards : towards) * cards * step, 0);
|
||||
};
|
||||
|
||||
const total = found.places.length + found.more;
|
||||
return (
|
||||
<section aria-label={t('assistant.place.strip')} className="-mx-4">
|
||||
{variant === 'window' && (fade.start || fade.end) && (
|
||||
// The cards are reachable by Tab; the arrows are for the pointer only.
|
||||
<div className="mb-1.5 flex items-center gap-2 px-4">
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-muted">
|
||||
{t('assistant.place.strip')}
|
||||
<span className="ms-1.5 tabular-nums text-content-faint">{total}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disabled={!fade.start}
|
||||
onClick={() => page(-1)}
|
||||
className={ARROW}
|
||||
>
|
||||
<ChevronLeft size={15} className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disabled={!fade.end}
|
||||
onClick={() => page(1)}
|
||||
className={ARROW}
|
||||
>
|
||||
<ChevronRight size={15} className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ul
|
||||
ref={listRef}
|
||||
onScroll={measure}
|
||||
data-fade-start={fade.start || undefined}
|
||||
data-fade-end={fade.end || undefined}
|
||||
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5 motion-safe:scroll-smooth"
|
||||
>
|
||||
{found.places.slice(0, SHOWN).map((place) => (
|
||||
<AssistantPlaceCard
|
||||
key={place.ref}
|
||||
place={place}
|
||||
strip={strip}
|
||||
suggested={suggestedFromStrip(message, place)}
|
||||
/>
|
||||
))}
|
||||
{rest > 0 && (
|
||||
<li className="flex w-24 flex-shrink-0 snap-start items-center justify-center rounded-2xl border border-dashed border-edge-faint px-2 text-center text-caption text-content-muted">
|
||||
{t('assistant.place.more', { count: rest })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user