Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 473132fc07 | |||
| e7a5316990 | |||
| 670c841866 |
@@ -53,54 +53,3 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
|
||||
|
||||
`server/assets/airports.json` is built from **OurAirports**
|
||||
(https://ourairports.com/data/), released into the public domain.
|
||||
|
||||
## Typefaces: Studio books and the app
|
||||
|
||||
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
|
||||
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
|
||||
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
|
||||
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
|
||||
copyright notice of the family's authors in its `LICENSE` file.
|
||||
|
||||
Both licences allow a font to be bundled with software and embedded in a
|
||||
document made with it, so a printed or exported book may carry the faces it is
|
||||
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
| Family | Package | Licence |
|
||||
|---|---|---|
|
||||
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
|
||||
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
|
||||
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
|
||||
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
|
||||
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
|
||||
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
|
||||
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
|
||||
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
|
||||
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
|
||||
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
|
||||
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
|
||||
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
|
||||
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
|
||||
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
|
||||
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
|
||||
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
|
||||
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
|
||||
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
|
||||
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
|
||||
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
|
||||
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
|
||||
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
|
||||
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
|
||||
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
|
||||
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
|
||||
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
|
||||
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
|
||||
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
|
||||
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
|
||||
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
|
||||
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
|
||||
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
|
||||
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
|
||||
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
|
||||
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
|
||||
|
||||
@@ -112,9 +112,6 @@ data:
|
||||
{{- if .Values.env.TREK_PLACES_URL }}
|
||||
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
|
||||
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.AMAP_API_BASE }}
|
||||
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -108,7 +108,6 @@ env:
|
||||
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
|
||||
# here. Defaults to on.
|
||||
# TREK_PLACES_URL: ""
|
||||
# GOOGLE_LIST_DATA_BASE: ""
|
||||
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
|
||||
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
|
||||
# Defaults to https://places.liketrek.com.
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Which font files the service worker installs with the app.
|
||||
*
|
||||
* The precache takes every woff and woff2 the build emits, and with the book's
|
||||
* catalogue at thirty four families that is every weight, both styles and
|
||||
* every script subset of each: megabytes fetched at install by every user,
|
||||
* most of it for faces no book of theirs is set in. So the book's faces are
|
||||
* held to this:
|
||||
*
|
||||
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
|
||||
* imported in src/main.tsx) stay exactly as they were.
|
||||
* - The faces the book shipped with before the catalogue grew
|
||||
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
|
||||
* still opens offline before Studio was ever opened online.
|
||||
* - Every other file of a book family, woff, other subsets, new families,
|
||||
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
|
||||
* which the Studio fills with the faces of each book it opens
|
||||
* (src/components/Studio/bookFontWarm.ts).
|
||||
*
|
||||
* Shared by vite.config.js and its test, which reads the filter against file
|
||||
* names as the build writes them.
|
||||
*/
|
||||
|
||||
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
|
||||
export const APP_FACES = {
|
||||
poppins: [300, 400, 500, 600, 700],
|
||||
museomoderno: [400, 700, 800],
|
||||
};
|
||||
|
||||
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
|
||||
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
|
||||
(a, b) => b.length - a.length
|
||||
);
|
||||
|
||||
const PRECACHED = new Map(
|
||||
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
|
||||
);
|
||||
|
||||
/**
|
||||
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
|
||||
* read against the book's packages; null for any other file.
|
||||
*/
|
||||
export function bookFontFile(url) {
|
||||
const name = url.split('/').pop() ?? '';
|
||||
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
|
||||
if (!match) return null;
|
||||
const [, head, weight, style, ext] = match;
|
||||
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
|
||||
if (!pkg) return null;
|
||||
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
|
||||
}
|
||||
|
||||
/** Whether a file of the build goes into the install-time precache. */
|
||||
export function keepInPrecache(url) {
|
||||
const file = bookFontFile(url);
|
||||
if (!file) return true;
|
||||
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
|
||||
const kept = PRECACHED.get(file.pkg);
|
||||
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
|
||||
}
|
||||
|
||||
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
|
||||
export function withoutRuntimeFonts(entries) {
|
||||
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
|
||||
}
|
||||
@@ -451,7 +451,7 @@ const releaseRecordedDay = (page: Page) => releaseJournalDawarich(page.request,
|
||||
// ── Studio ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const rail = (page: Page) => page.locator('.st-rail')
|
||||
const railButton = (page: Page, name: string) => rail(page).getByRole('tab', { name, exact: true })
|
||||
const railButton = (page: Page, name: string) => rail(page).getByRole('button', { name, exact: true })
|
||||
const sidePanel = (page: Page) => page.locator('.st-panel.st-side')
|
||||
const inspector = (page: Page) => page.locator('.st-inspector')
|
||||
const studioMenu = (page: Page) => page.locator('.st-menu').last()
|
||||
@@ -460,20 +460,6 @@ const exportDialog = (page: Page) => page.getByRole('dialog', { name: 'Export' }
|
||||
const thumbs = (page: Page) => page.locator('.st-thumb-row')
|
||||
/** The hit targets over the spread, one per element. */
|
||||
const sheetElements = (page: Page) => page.locator('.st-sheet > div[style*="left:"]')
|
||||
/**
|
||||
* The hit target of the first text on the spread on screen. The targets above the page are empty
|
||||
* boxes, so the one over the drawn words is found by where the words are and marked for the locator.
|
||||
*/
|
||||
async function markTextTarget(page: Page): Promise<void> {
|
||||
const words = page.locator('.st-sheet').getByText(/\p{L}{3,}/u).first()
|
||||
await expect(words).toBeVisible()
|
||||
await words.evaluate(el => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||||
hit?.setAttribute('data-help-text', '')
|
||||
})
|
||||
}
|
||||
const textTarget = (page: Page) => page.locator('.st-sheet [data-help-text]')
|
||||
|
||||
async function studioSection(page: Page, name: string): Promise<void> {
|
||||
await railButton(page, name).click()
|
||||
@@ -1314,7 +1300,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => p.locator('.st-head-close'),
|
||||
target: p => p.locator('.st-back'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1445,7 +1431,7 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('tab', { name: /Entries/ }).click()
|
||||
await sidePanel(p).locator('.st-tabs button').filter({ hasText: 'Entries' }).click()
|
||||
await expect(sidePanel(p).locator('.st-entry').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
@@ -1472,162 +1458,16 @@ const SCRIPTS: Record<string, GuideScript> = {
|
||||
],
|
||||
},
|
||||
|
||||
'studio-my-layouts': {
|
||||
guide: guide('studio-my-layouts'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layouts'),
|
||||
act: async p => { await studioSection(p, 'Layouts') },
|
||||
},
|
||||
{
|
||||
// Pointed at rather than pressed, so the seed book keeps no layout of its own for the guides after this one.
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Save this page as a layout' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-saved-layouts'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-theme': {
|
||||
guide: guide('studio-theme'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Theme'),
|
||||
act: async p => { await studioSection(p, 'Theme') },
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-theme-grid'),
|
||||
act: async p => {
|
||||
await sidePanel(p).locator('.st-theme-card').nth(1).click()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
await sidePanel(p).getByRole('button', { name: 'Apply to the book' }).scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => sidePanel(p).locator('.st-theme-apply'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-stickers': {
|
||||
guide: guide('studio-stickers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => { await studioSection(p, 'Elements') },
|
||||
target: p => sidePanel(p).getByRole('tab', { name: 'Stickers' }),
|
||||
act: async p => {
|
||||
await sidePanel(p).getByRole('tab', { name: 'Stickers' }).click()
|
||||
await expect(sidePanel(p).locator('.st-shape-grid').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-shape-grid').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-travel': {
|
||||
guide: guide('studio-travel'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
target: p => railButton(p, 'Travel'),
|
||||
act: async p => {
|
||||
await studioSection(p, 'Travel')
|
||||
await expect(sidePanel(p).locator('.st-travel-card').first()).toBeVisible({ timeout: 15_000 })
|
||||
await settle(p)
|
||||
},
|
||||
act: async p => { await studioSection(p, 'Travel') },
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-travel-grid').first(),
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-travel-card').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-text-effects': {
|
||||
guide: guide('studio-text-effects'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
await markTextTarget(p)
|
||||
},
|
||||
target: p => textTarget(p),
|
||||
act: async p => {
|
||||
await textTarget(p).click()
|
||||
await expect(inspector(p).getByRole('button', { name: 'Effects' })).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
prepare: async p => {
|
||||
const head = inspector(p).getByRole('button', { name: 'Effects' })
|
||||
if ((await head.getAttribute('aria-expanded')) !== 'true') await head.click()
|
||||
await head.scrollIntoViewIfNeeded()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ has: p.getByRole('button', { name: 'Effects' }) }),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-background': {
|
||||
guide: guide('studio-background'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => inspector(p).locator('.st-section').filter({ hasText: 'Page background' }).first(),
|
||||
},
|
||||
{
|
||||
target: p => inspector(p).locator('.st-paint-tabs').first(),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'studio-layers': {
|
||||
guide: guide('studio-layers'),
|
||||
start: openStudio,
|
||||
steps: [
|
||||
{
|
||||
prepare: async p => {
|
||||
await studioSection(p, 'Pages')
|
||||
await thumbs(p).nth(2).locator('.st-thumb').click()
|
||||
await settle(p)
|
||||
},
|
||||
target: p => railButton(p, 'Layers'),
|
||||
act: async p => {
|
||||
await studioSection(p, 'Layers')
|
||||
await expect(sidePanel(p).locator('.st-layer').first()).toBeVisible()
|
||||
await settle(p)
|
||||
},
|
||||
},
|
||||
{
|
||||
target: p => sidePanel(p).locator('.st-layer').first(),
|
||||
target: sidePanel,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -47,41 +47,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
"@fontsource/alfa-slab-one": "^5.3.0",
|
||||
"@fontsource/allura": "^5.3.0",
|
||||
"@fontsource/anton": "^5.3.0",
|
||||
"@fontsource/archivo-black": "^5.3.0",
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/big-shoulders-display": "^5.3.0",
|
||||
"@fontsource/bodoni-moda": "^5.3.0",
|
||||
"@fontsource/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource/caveat": "^5.3.0",
|
||||
"@fontsource/cormorant-garamond": "^5.3.0",
|
||||
"@fontsource/dancing-script": "^5.3.0",
|
||||
"@fontsource/dm-mono": "^5.3.0",
|
||||
"@fontsource/dm-serif-display": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/fraunces": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.3.0",
|
||||
"@fontsource/great-vibes": "^5.3.0",
|
||||
"@fontsource/homemade-apple": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/kaushan-script": "^5.3.0",
|
||||
"@fontsource/league-gothic": "^5.3.0",
|
||||
"@fontsource/lora": "^5.3.0",
|
||||
"@fontsource/montserrat": "^5.3.0",
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/oswald": "^5.3.0",
|
||||
"@fontsource/pacifico": "^5.3.0",
|
||||
"@fontsource/parisienne": "^5.3.0",
|
||||
"@fontsource/permanent-marker": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.3.0",
|
||||
"@fontsource/sacramento": "^5.3.0",
|
||||
"@fontsource/satisfy": "^5.3.0",
|
||||
"@fontsource/shrikhand": "^5.3.0",
|
||||
"@fontsource/yellowtail": "^5.3.0",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
@@ -108,7 +81,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"zod": "^4.6.7",
|
||||
"zod": "^4.6.5",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 384,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 854,
|
||||
"no-console": 88,
|
||||
"complexity": 221,
|
||||
"complexity": 226,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
"trek/no-swallowed-catch": 131,
|
||||
"max-params": 33,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 556,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"react-hooks/refs": 557,
|
||||
"react-hooks/set-state-in-effect": 195,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"@typescript-eslint/no-unused-vars": 126,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 130,
|
||||
"max-lines-per-function": 132,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
"react-refresh/only-export-components": 45,
|
||||
"react-refresh/only-export-components": 46,
|
||||
"unused-disable-directive": 6,
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
@@ -34,8 +34,8 @@
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1929,
|
||||
"@typescript-eslint/no-explicit-any": 491,
|
||||
"@typescript-eslint/no-non-null-assertion": 1932,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -69,15 +69,6 @@
|
||||
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "AI assistant home chat",
|
||||
"hooks": ["src/components/Assistant/useAssistantHome.ts"],
|
||||
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/assistant/MAssistantHomeScreen.tsx",
|
||||
"src/mobile/screens/dashboard/MDashboard.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Assistant hints",
|
||||
"hooks": ["src/components/Assistant/useAssistantHints.ts"],
|
||||
@@ -601,6 +592,7 @@
|
||||
"hooks": ["src/components/Planner/usePlaceActions.ts"],
|
||||
"desktop": [
|
||||
"src/components/Planner/PlaceInspector.tsx",
|
||||
"src/components/shared/PlaceAvatarUpload.tsx",
|
||||
"src/pages/tripPlanner/useTripPlannerPage.ts"
|
||||
],
|
||||
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
|
||||
|
||||
@@ -1123,6 +1123,8 @@
|
||||
"src/components/shared/PasswordChecklist.tsx",
|
||||
"src/components/shared/PlaceAvatar.test.tsx",
|
||||
"src/components/shared/PlaceAvatar.tsx",
|
||||
"src/components/shared/PlaceAvatarUpload.test.tsx",
|
||||
"src/components/shared/PlaceAvatarUpload.tsx",
|
||||
"src/components/shared/PluginIcon.test.tsx",
|
||||
"src/components/shared/PluginIcon.tsx",
|
||||
"src/components/shared/PublicLanguagePicker.test.tsx",
|
||||
|
||||
@@ -117,6 +117,11 @@ export const DYNAMIC_ALLOWED = [
|
||||
template: 'settings.${mode}',
|
||||
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
|
||||
},
|
||||
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
|
||||
file,
|
||||
template: 'journey.studio.${key}',
|
||||
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
|
||||
})),
|
||||
{
|
||||
file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
|
||||
template: 'reservations.${s}',
|
||||
@@ -139,20 +144,6 @@ export const DYNAMIC_ALLOWED = [
|
||||
'memories.* key in en',
|
||||
}))
|
||||
),
|
||||
{
|
||||
file: 'components/Studio/stickerLibrary.ts',
|
||||
template: 'journey.studio.sticker.${id}',
|
||||
because:
|
||||
'id is a sticker id of the library in @trek/shared/stickers; stickerLibrary.test.ts resolves the key of every ' +
|
||||
'sticker the library holds against en',
|
||||
},
|
||||
{
|
||||
file: 'components/Studio/shapes.ts',
|
||||
template: 'journey.studio.shapeName.${shape}',
|
||||
because:
|
||||
'shapeName takes a BookShapeId; shared/src/book/book-shape-catalogue.spec.ts holds an en name for every ' +
|
||||
'shape of BOOK_SHAPE_GROUPS',
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"packing.ts": 6,
|
||||
"pdf.ts": 1,
|
||||
"perm.ts": 4,
|
||||
"places.ts": 9,
|
||||
"places.ts": 10,
|
||||
"planner.ts": 53,
|
||||
"reservations.ts": 21,
|
||||
"roadtrip.ts": 36,
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
|
||||
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
|
||||
"src/components/Studio/StudioShell.tsx": 1,
|
||||
"src/components/Studio/sidebar/StudioEntryCard.tsx": 1,
|
||||
"src/components/Studio/travel/BadgeIcon.tsx": 1,
|
||||
"src/components/Studio/StudioSidebar.tsx": 1,
|
||||
"src/components/Studio/TravelElements.tsx": 1,
|
||||
"src/components/Todo/useTodoList.ts": 1,
|
||||
"src/components/shared/ConfirmDialog.tsx": 1,
|
||||
"src/components/shared/CountryFlag.tsx": 1,
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"src/components/Vacay/useVacayCalendarLogic.ts",
|
||||
"src/components/Vacay/useVacaySettings.ts",
|
||||
"src/components/Vacay/useVacayUserPicker.ts",
|
||||
"src/components/shared/PlaceAvatarUpload.tsx",
|
||||
"src/hooks/useAirtrailConnection.ts",
|
||||
"src/hooks/useDawarichConnection.ts",
|
||||
"src/hooks/useDawarichSuggestions.ts",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"components/Journey/JourneyMap.tsx": 1,
|
||||
"components/Journey/JourneyMapGL.tsx": 2,
|
||||
"components/Journey/MobileEntryView.tsx": 1,
|
||||
"components/Journey/PhotoLightbox.tsx": 3,
|
||||
"components/Journey/journeyPhotoLayer.ts": 1,
|
||||
"components/Layout/Navbar.tsx": 1,
|
||||
"components/Layout/OfflineBanner.tsx": 1,
|
||||
@@ -51,12 +52,13 @@
|
||||
"components/Studio/BookSheetsView.tsx": 3,
|
||||
"components/Studio/PageNumbers.tsx": 1,
|
||||
"components/Studio/PeerCursors.tsx": 1,
|
||||
"components/Studio/PhotoView.tsx": 4,
|
||||
"components/Studio/SpreadView.tsx": 4,
|
||||
"components/Studio/StudioCanvas.tsx": 12,
|
||||
"components/Studio/SpreadView.tsx": 8,
|
||||
"components/Studio/StudioCanvas.tsx": 22,
|
||||
"components/Studio/StudioElementsPanel.tsx": 1,
|
||||
"components/Studio/StudioExport.tsx": 1,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
"components/Studio/StudioSavedLayouts.tsx": 1,
|
||||
"components/Studio/StudioSidebar.tsx": 2,
|
||||
"components/Studio/TravelElements.tsx": 4,
|
||||
"components/Studio/TravelPreview.tsx": 1,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
|
||||
@@ -71,6 +73,7 @@
|
||||
"components/shared/DetailShell.tsx": 1,
|
||||
"components/shared/ElevationProfile.tsx": 2,
|
||||
"components/shared/NavigationMenu.tsx": 2,
|
||||
"components/shared/PlaceAvatarUpload.tsx": 3,
|
||||
"components/shared/PublicLanguagePicker.tsx": 1,
|
||||
"components/shared/SlidingTabs.tsx": 3,
|
||||
"components/shared/StarRating.tsx": 3,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"components/Studio/LineHandles.tsx": 1,
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1,
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1
|
||||
}
|
||||
|
||||
@@ -8,23 +8,23 @@
|
||||
"src/components/Map/MapView.tsx": 1399,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/RouteCalculator.ts": 1076,
|
||||
"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": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2936,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2953,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1157,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1193,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
"src/components/Studio/TravelElements.tsx": 1738,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/TripPlannerPage.tsx": 1131,
|
||||
"src/pages/atlas/useAtlas.ts": 1211,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
|
||||
"src/styles/studio.css": 1918
|
||||
"src/styles/studio.css": 2087
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 3,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1,
|
||||
"src/components/Planner/PlaceInspector.tsx": 16,
|
||||
"src/components/Planner/PlaceInspector.tsx": 17,
|
||||
"src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1,
|
||||
"src/components/Planner/PlacesSidebar.tsx": 1,
|
||||
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
|
||||
@@ -82,6 +82,8 @@
|
||||
"src/components/Settings/NotificationsTab.test.tsx": 1,
|
||||
"src/components/Settings/PasskeysSection.tsx": 1,
|
||||
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
|
||||
"src/components/Studio/StudioCanvas.tsx": 1,
|
||||
"src/components/Studio/StudioInspector.tsx": 1,
|
||||
"src/components/Todo/todoListModel.ts": 1,
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||
"src/components/Tours/planner/useTourPlanner.ts": 2,
|
||||
@@ -96,6 +98,7 @@
|
||||
"src/components/shared/CustomSelect.test.tsx": 1,
|
||||
"src/components/shared/PlaceAvatar.test.tsx": 2,
|
||||
"src/components/shared/PlaceAvatar.tsx": 1,
|
||||
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
|
||||
"src/hooks/useDawarichSuggestions.test.ts": 1,
|
||||
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"components/Journey/moodConfig.ts": 5,
|
||||
"components/Layout/BottomNav.tsx": 3,
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 3,
|
||||
"components/Layout/InAppNotificationBell.tsx": 8,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
@@ -72,6 +72,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/Notifications/InAppNotificationItem.tsx": 8,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
@@ -99,13 +100,13 @@
|
||||
"components/Roadtrip/serviceMarker.ts": 1,
|
||||
"components/Settings/useAppearanceEditor.ts": 5,
|
||||
"components/Studio/BookSheetsView.tsx": 2,
|
||||
"components/Studio/PhotoView.tsx": 2,
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 3,
|
||||
"components/Studio/StudioElementsPanel.tsx": 8,
|
||||
"components/Studio/StudioInspector.tsx": 1,
|
||||
"components/Studio/StudioShell.tsx": 2,
|
||||
"components/Studio/StudioSidebar.tsx": 2,
|
||||
"components/Studio/TravelElements.tsx": 5,
|
||||
"components/Studio/printSheets.ts": 5,
|
||||
"components/Studio/sidebar/contentDrops.ts": 2,
|
||||
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
|
||||
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
|
||||
"components/SystemNotices/SystemNoticeModal.tsx": 107,
|
||||
@@ -131,6 +132,7 @@
|
||||
"components/shared/Modal.tsx": 3,
|
||||
"components/shared/NavigationMenu.tsx": 1,
|
||||
"components/shared/NoteColorPicker.tsx": 2,
|
||||
"components/shared/PlaceAvatarUpload.tsx": 3,
|
||||
"components/shared/PublicLanguagePicker.tsx": 3,
|
||||
"components/shared/Skeleton.tsx": 2,
|
||||
"components/shared/Spinner.tsx": 2,
|
||||
@@ -250,7 +252,7 @@
|
||||
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
|
||||
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
|
||||
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23,
|
||||
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 27,
|
||||
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
|
||||
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
|
||||
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
|
||||
@@ -294,6 +296,7 @@
|
||||
"pages/FilesPage.tsx": 11,
|
||||
"pages/ForgotPasswordPage.tsx": 18,
|
||||
"pages/HelpPage.tsx": 21,
|
||||
"pages/InAppNotificationsPage.tsx": 9,
|
||||
"pages/JoinTripPage.tsx": 4,
|
||||
"pages/JourneyDetailPage.tsx": 53,
|
||||
"pages/JourneyPage.tsx": 64,
|
||||
|
||||
@@ -40,14 +40,12 @@
|
||||
"components/Settings/AppearanceSettingsTab.tsx": 1,
|
||||
"components/Settings/PasskeysSection.tsx": 1,
|
||||
"components/Studio/BookSheetsView.tsx": 1,
|
||||
"components/Studio/PhotoView.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 1,
|
||||
"components/Studio/SpreadView.tsx": 2,
|
||||
"components/Studio/StudioElementsPanel.tsx": 2,
|
||||
"components/Studio/StudioSwatches.tsx": 1,
|
||||
"components/Studio/TravelElements.tsx": 3,
|
||||
"components/Studio/TravelElements.tsx": 6,
|
||||
"components/Studio/peerColour.ts": 1,
|
||||
"components/Studio/printSheets.ts": 4,
|
||||
"components/Studio/travel/badgeLook.ts": 2,
|
||||
"components/Trips/TripMemberAvatar.tsx": 2,
|
||||
"components/Vacay/VacayPersons.tsx": 1,
|
||||
"components/shared/DialogShell.tsx": 2,
|
||||
|
||||
@@ -67,7 +67,6 @@ const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePag
|
||||
const TourSaveToCollectionHost = lazyWithRetry(() => import('./components/Tours/collection/TourSaveToCollectionHost'))
|
||||
// The desktop assistant window: its own chunk, and nothing at all until a connection is set up.
|
||||
const AssistantHost = lazyWithRetry(() => import('./components/Assistant/AssistantHost'))
|
||||
const MAssistantHomeScreen = lazyWithRetry(() => import('./mobile/screens/assistant/MAssistantHomeScreen'))
|
||||
|
||||
// The ten phone screens are chunks of their own, alongside the desktop pages
|
||||
// rather than inside them. Each page used to import its M screen statically and
|
||||
@@ -411,10 +410,10 @@ export default function App() {
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
{!hideAuthedWidgets && keepingTour && <ErrorBoundary boundaryId="widget:keep-tour" fallback={null}><Suspense fallback={null}><TourSaveToCollectionHost /></Suspense></ErrorBoundary>}
|
||||
{/* The phone has its own assistant screen inside the trip, and the home chat's outside it. */}
|
||||
{!hideAuthedWidgets && isAuthenticated && (
|
||||
{/* The phone has its own assistant screen inside the trip. */}
|
||||
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
|
||||
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
|
||||
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <AssistantHost />}</Suspense>
|
||||
<Suspense fallback={null}><AssistantHost /></Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
|
||||
@@ -165,46 +165,4 @@ describe('assistantApi JSON calls', () => {
|
||||
await assistantApi.stop(journey);
|
||||
expect(hits).toEqual(['threads', 'undo', 'stop']);
|
||||
});
|
||||
|
||||
it('FE-AI-API-009: the home scope reaches the routes outside any trip', async () => {
|
||||
const home = { kind: 'home' } as const;
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => streamResponse('data: [DONE]\n\n'));
|
||||
const signal = new AbortController().signal;
|
||||
await assistantApi.chat(home, BODY, signal);
|
||||
await assistantApi.decide(home, 7, { locale: 'en', timeZone: 'UTC', decisions: [] }, signal);
|
||||
expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/assistant/chat',
|
||||
'/api/assistant/threads/7/decisions',
|
||||
]);
|
||||
|
||||
const hits: string[] = [];
|
||||
server.use(
|
||||
http.get('/api/assistant/threads', () => {
|
||||
hits.push('threads');
|
||||
return HttpResponse.json({ threads: [] });
|
||||
}),
|
||||
http.get('/api/assistant/threads/3', () => {
|
||||
hits.push('thread');
|
||||
return HttpResponse.json({ thread: { id: 3, title: 't', lastMessageAt: 'x' }, messages: [] });
|
||||
}),
|
||||
http.delete('/api/assistant/threads/3', () => {
|
||||
hits.push('delete');
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
http.post('/api/assistant/tool-calls/:id/undo', () => {
|
||||
hits.push('undo');
|
||||
return HttpResponse.json({ undone: true });
|
||||
}),
|
||||
http.post('/api/assistant/stop', () => {
|
||||
hits.push('stop');
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
await assistantApi.threads(home);
|
||||
await assistantApi.thread(home, 3);
|
||||
await assistantApi.deleteThread(home, 3);
|
||||
await assistantApi.undo(home, 'c1');
|
||||
await assistantApi.stop(home);
|
||||
expect(hits).toEqual(['threads', 'thread', 'delete', 'undo', 'stop']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type AiApiKeyConnectRequest,
|
||||
type AiAuthStartResponse,
|
||||
type AiChatgptFinishRequest,
|
||||
type AiClaudeConnectorResponse,
|
||||
type AiConnectionScope,
|
||||
type AiConnectionView,
|
||||
type AiInstanceConnectionRequest,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
type AssistantChatRequest,
|
||||
type AssistantDecisionsRequest,
|
||||
type AssistantErrorCode,
|
||||
type AssistantInsertFoundPlaceRequest,
|
||||
type AssistantRenderUploadResponse,
|
||||
type AssistantScope,
|
||||
type AssistantShareRequest,
|
||||
@@ -102,16 +102,14 @@ const tripBase = (tripId: number) => `/trips/${tripId}/assistant`;
|
||||
|
||||
/**
|
||||
* What a conversation call is about: a trip's id (the planner's chat), or a
|
||||
* scope, which also reaches a journey's chat in TREK Studio and the home chat
|
||||
* outside any one trip. Threads, turns, decisions, undo and stop exist under
|
||||
* all three; files, found places, proposals and sharing belong to a trip and
|
||||
* take its id.
|
||||
* scope, which also reaches a journey's chat in TREK Studio. Threads, turns,
|
||||
* decisions, undo and stop exist under both; files, found places, proposals
|
||||
* and sharing belong to a trip and take its id.
|
||||
*/
|
||||
export type AssistantTarget = number | AssistantScope;
|
||||
|
||||
function scopeBase(target: AssistantTarget): string {
|
||||
if (typeof target === 'number') return tripBase(target);
|
||||
if (target.kind === 'home') return '/assistant';
|
||||
return target.kind === 'trip' ? tripBase(target.tripId) : `/journeys/${target.journeyId}/assistant`;
|
||||
}
|
||||
|
||||
@@ -145,13 +143,6 @@ export const assistantApi = {
|
||||
body: AssistantAddFoundPlaceRequest
|
||||
): Promise<AssistantAddFoundPlaceResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
|
||||
// "+ Tour": a found place taken into the saved tour open in the planner, held as a change like "+ Day 3".
|
||||
insertFoundPlace: (
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
body: AssistantInsertFoundPlaceRequest
|
||||
): Promise<AssistantAddFoundPlaceResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places/tour`, body).then((r) => r.data),
|
||||
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
|
||||
takeProposal: (
|
||||
tripId: number,
|
||||
@@ -232,3 +223,7 @@ export const aiAdminApi = {
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -712,33 +712,4 @@ describe('AddonManager', () => {
|
||||
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
|
||||
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-045: finds an addon by name or description and shows the ones on or off', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'x-lists', name: 'Todo List', description: 'Track tasks', type: 'trip', enabled: true }),
|
||||
buildAddon({ id: 'x-costs', name: 'Budget', description: 'Split costs', type: 'trip', enabled: false }),
|
||||
buildAddon({ id: 'x-holiday', name: 'Vacay', description: 'Holiday planner', type: 'global', enabled: true }),
|
||||
])
|
||||
);
|
||||
render(<AddonManager />);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'costs');
|
||||
expect(screen.getByText('Budget')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Todo List')).toBeNull();
|
||||
expect(screen.queryByText('Vacay')).toBeNull();
|
||||
|
||||
await user.clear(screen.getByRole('searchbox', { name: 'Search addons' }));
|
||||
await user.click(screen.getByRole('button', { name: 'On' }));
|
||||
expect(screen.getByText('Todo List')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vacay')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Budget')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Off' }));
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Search addons' }), 'nothing like this');
|
||||
expect(screen.getByText('No addon matches the search or the filter.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, 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, Bookmark, Users, Loader2, Mountain, CarFront, Search } from 'lucide-react'
|
||||
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'
|
||||
@@ -76,8 +76,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
const [query, setQuery] = useState('')
|
||||
const [view, setView] = useState<AddonView>('all')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -196,10 +194,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
}
|
||||
|
||||
/* A card per type, the way every other admin tab sets its cards: Trip in the
|
||||
left column, Global and Integration in the right one, each card with the
|
||||
dialogs' head band carrying the type, its count and what it means. Above
|
||||
them one card says how many are on and finds an addon by name. */
|
||||
/* One column per type, side by side, instead of three stacked sections. Stacked, each
|
||||
section opened its own set of columns and the tall tiles (Collab, Journey) set a
|
||||
section's height while its neighbours ran out early. Side by side the column's
|
||||
head carries the type, so the tiles need no badge of their own. */
|
||||
const groups = [
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
@@ -216,145 +214,68 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
</span>
|
||||
)
|
||||
|
||||
/** Whether an addon is shown under the search and the filter. */
|
||||
const shown = (addon: Addon) => {
|
||||
if (view === 'on' && !addon.enabled) return false
|
||||
if (view === 'off' && addon.enabled) return false
|
||||
const q = query.trim().toLocaleLowerCase()
|
||||
if (!q) return true
|
||||
const label = getAddonLabel(t, addon)
|
||||
return `${label.name} ${label.description ?? ''}`.toLocaleLowerCase().includes(q)
|
||||
}
|
||||
|
||||
const card = (g: (typeof groups)[number]) => {
|
||||
const meta = TYPE_META[g.key]
|
||||
const all = g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons
|
||||
const visible = g.addons.filter(shown)
|
||||
const ai = g.key === 'integration' && aiAddons.some(shown)
|
||||
if (!visible.length && !ai) return null
|
||||
const on = all.filter(a => a.enabled).length
|
||||
return (
|
||||
<SettingsCard
|
||||
key={g.key}
|
||||
icon={meta.icon}
|
||||
title={t(meta.labelKey)}
|
||||
hint={t(meta.hintKey)}
|
||||
badge={(
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: on, total: all.length })}>
|
||||
<span className="inline-flex"><StatusPill tone={on > 0 ? 'success' : 'neutral'}>{on}/{all.length}</StatusPill></span>
|
||||
</Tooltip>
|
||||
)}
|
||||
bodyClassName="p-3"
|
||||
>
|
||||
<div className="divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
|
||||
{/* The AI addons share a connection and a settings dialog, so they head the card as one tile. */}
|
||||
{ai && <AiAddonTile addons={aiAddons} onToggle={handleToggle} />}
|
||||
{visible.map(tile)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
const [first, ...rest] = groups
|
||||
const cards = { start: first ? card(first) : null, end: rest.map(card).filter(Boolean) }
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
>
|
||||
{addons.length === 0
|
||||
? <EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
: <AddonToolbar groups={groups.map(g => ({ key: g.key, addons: g.key === 'integration' ? [...aiAddons, ...g.addons] : g.addons }))} query={query} onQuery={setQuery} view={view} onView={setView} t={t} />}
|
||||
</SettingsCard>
|
||||
|
||||
{addons.length > 0 && (cards.start || cards.end.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
|
||||
<div className="min-w-0">{cards.start}</div>
|
||||
<div className="min-w-0">{cards.end}</div>
|
||||
</div>
|
||||
<SettingsCard
|
||||
icon={Puzzle}
|
||||
title={t('admin.addons.title')}
|
||||
hint={subtitle}
|
||||
badge={totalCount > 0 ? (
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
>
|
||||
{addons.length === 0 ? (
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
|
||||
) : (
|
||||
<p className="m-0 rounded-2xl border border-dashed border-edge-faint px-4 py-8 text-center text-content-muted" style={fs(13, 'body')}>
|
||||
{t('admin.addons.noMatch')}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
type AddonView = 'all' | 'on' | 'off'
|
||||
|
||||
/**
|
||||
* The head card's body: a tile per type with how many of it are on, then a
|
||||
* search over the addons' names and descriptions and a switch between all,
|
||||
* the ones on and the ones off.
|
||||
*/
|
||||
function AddonToolbar({ groups, query, onQuery, view, onView, t }: {
|
||||
groups: { key: string; addons: Addon[] }[]
|
||||
query: string
|
||||
onQuery: (q: string) => void
|
||||
view: AddonView
|
||||
onView: (v: AddonView) => void
|
||||
/** The head band of one type's column: icon tile, the type, its count and what it means. */
|
||||
function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
|
||||
label: string
|
||||
hint: string
|
||||
addons: Addon[]
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}) {
|
||||
const enabled = addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
const Icon = meta.icon
|
||||
const on = g.addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div key={g.key} className="flex items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-secondary text-content-secondary">
|
||||
<Icon size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t(meta.labelKey)}</span>
|
||||
<span className="block text-content-faint" style={fs(11)}>{t('admin.addons.group.count', { enabled: on, total: g.addons.length })}</span>
|
||||
</span>
|
||||
<span className={`font-geist font-bold tabular-nums ${on > 0 ? 'text-content' : 'text-content-faint'}`} style={fs(18, 'body')} aria-hidden>{on}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<label className="relative flex min-w-0 flex-1 items-center">
|
||||
<Search size={14} className="pointer-events-none absolute start-3 text-content-faint" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => onQuery(e.target.value)}
|
||||
placeholder={t('admin.addons.search')}
|
||||
aria-label={t('admin.addons.search')}
|
||||
className="h-9 w-full rounded-[10px] border border-edge-faint bg-surface-card ps-8 pe-3 text-content outline-none transition-colors placeholder:text-content-faint focus:border-accent"
|
||||
style={fs(13, 'body')}
|
||||
/>
|
||||
</label>
|
||||
<div className="inline-flex flex-none gap-1 rounded-[10px] bg-surface-tertiary p-1" role="group" aria-label={t('admin.addons.filter')}>
|
||||
{(['all', 'on', 'off'] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => onView(v)}
|
||||
aria-pressed={view === v}
|
||||
className={`h-7 rounded-[8px] px-3 font-medium transition-colors ${view === v ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t(`admin.addons.filter.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
|
||||
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
|
||||
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill>{enabled}/{addons.length}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Says what the type means: the only place that still explains it. */}
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -102,13 +102,6 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h5') {
|
||||
return (
|
||||
<h5 key={idx} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(12.5, 'body')}>
|
||||
{block.text}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
if (block.kind === 'h4') {
|
||||
return (
|
||||
<h4 key={idx} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ArrowUp, CalendarDays, MapPin, Paperclip, Square, X } from 'lucide-reac
|
||||
import { type ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import PlaceAvatar from '../shared/PlaceAvatar';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
@@ -46,11 +45,7 @@ export default function AssistantComposer({ tripId, tripTitle }: { tripId: numbe
|
||||
const empty = useAuiState((s) => s.composer.isEmpty);
|
||||
const { take, busy, accept, canAttach } = useAssistantAttach(tripId);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
// The home chat is about no one trip, so its placeholder names none.
|
||||
const fallback =
|
||||
tripId === HOME_CHAT_KEY
|
||||
? t('assistant.home.placeholder')
|
||||
: t('assistant.composer.placeholder', { trip: tripTitle });
|
||||
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
|
||||
const placeholder = useTypewriterPlaceholder(
|
||||
suggestions.map((s) => s.prompt),
|
||||
fallback,
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import {
|
||||
Ellipsis,
|
||||
History,
|
||||
House,
|
||||
Lightbulb,
|
||||
LightbulbOff,
|
||||
Luggage,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
Settings,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type HTMLAttributes, type MouseEvent, useRef, useState } from 'react';
|
||||
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';
|
||||
@@ -24,9 +12,7 @@ import Tooltip from '../shared/Tooltip';
|
||||
import { PopoverListPanel } from '../shared/dialogParts';
|
||||
import { usePopoverList } from '../shared/usePopoverList';
|
||||
import AssistantMemoryDialog from './AssistantMemoryDialog';
|
||||
import AssistantTripPicker from './AssistantTripPicker';
|
||||
import { AI_SETTINGS_PATH } from './assistantParts';
|
||||
import type { AssistantChatTrip } from './assistantViewContext';
|
||||
import { useAssistantHintsSetting } from './useAssistantHints';
|
||||
import { useAssistantLiveStep } from './useAssistantLiveStep';
|
||||
import type { useAssistantThread } from './useAssistantThread';
|
||||
@@ -43,16 +29,6 @@ interface AssistantHeaderProps {
|
||||
onClose: () => void;
|
||||
/** Moves the window: spread on the title, which is the part of the header to grab. */
|
||||
dragProps: HTMLAttributes<HTMLElement>;
|
||||
/**
|
||||
* The way between the home chat and one trip's: the home chat picks a trip
|
||||
* (`onPick`), a trip's chat opened from outside it goes back (`onHome`).
|
||||
*/
|
||||
tripSwitch?: TripSwitch;
|
||||
}
|
||||
|
||||
interface TripSwitch {
|
||||
onPick?: (trip: AssistantChatTrip) => void;
|
||||
onHome?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,14 +39,7 @@ interface TripSwitch {
|
||||
* keeps the conversation, and an answer being written goes on. The model is not picked here;
|
||||
* that lives in the settings. Notes are a trip's: a journey's chat in TREK Studio shows no notes button.
|
||||
*/
|
||||
export default function AssistantHeader({
|
||||
tripId,
|
||||
thread,
|
||||
tripTitle,
|
||||
onClose,
|
||||
dragProps,
|
||||
tripSwitch,
|
||||
}: AssistantHeaderProps) {
|
||||
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const live = useAssistantLiveStep(tripId);
|
||||
const memory = useTripMemory(tripId);
|
||||
@@ -79,7 +48,6 @@ export default function AssistantHeader({
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const menu = useContextMenu();
|
||||
const moreRef = useRef<HTMLButtonElement>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const threads = thread.threads ?? [];
|
||||
const history = usePopoverList(threads.findIndex((th) => th.id === thread.threadId) + 1);
|
||||
@@ -96,14 +64,6 @@ export default function AssistantHeader({
|
||||
run();
|
||||
};
|
||||
|
||||
// Closed by Escape or a pick while it holds the focus, the menu hands it back to its button, so the keys
|
||||
// go on reaching the window; a click or a scroll elsewhere leaves the focus where it went.
|
||||
const closeMenu = () => {
|
||||
const fromMenu = document.activeElement?.closest('[role="menu"]') != null;
|
||||
menu.close();
|
||||
if (fromMenu) moreRef.current?.focus();
|
||||
};
|
||||
|
||||
const openMenu = (e: MouseEvent) =>
|
||||
menu.open(
|
||||
e,
|
||||
@@ -162,7 +122,6 @@ export default function AssistantHeader({
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{tripSwitch && <TripSwitchButton {...tripSwitch} />}
|
||||
{tripChat && (
|
||||
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
|
||||
<button
|
||||
@@ -196,12 +155,10 @@ export default function AssistantHeader({
|
||||
</Tooltip>
|
||||
<Tooltip label={t('assistant.window.menu')}>
|
||||
<button
|
||||
ref={moreRef}
|
||||
type="button"
|
||||
onClick={openMenu}
|
||||
aria-label={t('assistant.window.menu')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu.menu !== null}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<Ellipsis size={14} aria-hidden="true" />
|
||||
@@ -229,7 +186,6 @@ export default function AssistantHeader({
|
||||
icon={null}
|
||||
label={th.title}
|
||||
active={th.id === thread.threadId}
|
||||
current={th.id === thread.threadId}
|
||||
onClick={() => pick(() => thread.openThread(th.id))}
|
||||
trailing={
|
||||
<span className="flex-shrink-0 text-caption text-content-faint">
|
||||
@@ -246,7 +202,7 @@ export default function AssistantHeader({
|
||||
)}
|
||||
</PopoverListPanel>
|
||||
|
||||
<ContextMenu menu={menu.menu} onClose={closeMenu} />
|
||||
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||
{memoryOpen && <AssistantMemoryDialog tripId={tripId} onClose={() => setMemoryOpen(false)} />}
|
||||
<ConfirmDialog
|
||||
isOpen={confirming}
|
||||
@@ -262,49 +218,3 @@ export default function AssistantHeader({
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Between the home chat and a trip's: from the home chat a list of the user's
|
||||
* trips (the running and the next one first) opens that trip's chat in the
|
||||
* window; from a trip's chat opened outside it, one press goes back home.
|
||||
*/
|
||||
function TripSwitchButton({ onPick, onHome }: TripSwitch) {
|
||||
const { t } = useTranslation();
|
||||
const list = usePopoverList(0, { estimatedHeight: 360 });
|
||||
const { open, triggerRef, listId, toggle, close } = list;
|
||||
if (onHome) {
|
||||
return (
|
||||
<Tooltip label={t('assistant.home.back')}>
|
||||
<button type="button" onClick={onHome} aria-label={t('assistant.home.back')} className={ICON_BUTTON}>
|
||||
<House size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (!onPick) return null;
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={t('assistant.home.switch')}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={t('assistant.home.switch')}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<Luggage size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<PopoverListPanel list={list} label={t('assistant.picker.title')} className="flex w-72 flex-col">
|
||||
<AssistantTripPicker
|
||||
onPick={({ id, title }) => {
|
||||
close();
|
||||
onPick({ id, title });
|
||||
}}
|
||||
/>
|
||||
</PopoverListPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ function useRouteTripId(): number | 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, outside
|
||||
* one it is the home chat about all of them (AssistantWindow); what a
|
||||
* keeps going from page to page. Inside a trip it is about that trip; what a
|
||||
* card or an applied change opens goes through the planner's links: "Show on
|
||||
* map" opens the planner on the change (`?place=`, `?day=`), the same way a
|
||||
* notification opens a place, a tab of the trip opens as `?tab=` (a list's
|
||||
|
||||
@@ -101,14 +101,6 @@ describe('AssistantLauncher', () => {
|
||||
act(() => useAssistantStore.getState().open());
|
||||
expect(button).not.toHaveAttribute('aria-busy');
|
||||
});
|
||||
|
||||
it('FE-AI-LAUNCH-008: the floating button breathes for an answer of the home chat being written too', () => {
|
||||
const home = assistantChatKey({ kind: 'home' });
|
||||
const running = { ...chatOf(useAssistantStore.getState(), home), isRunning: true };
|
||||
useAssistantStore.setState({ status: STATUS, byTrip: { [home]: running } });
|
||||
render(<AssistantLauncher variant="floating" />);
|
||||
expect(screen.getByRole('button', { name: 'Assistant' })).toHaveAttribute('aria-busy', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shortcut', () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ 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 home chat. Nothing shows until 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.
|
||||
@@ -35,11 +35,11 @@ export default function AssistantLauncher({
|
||||
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. A
|
||||
// trip's chat and the home chat count: an answer still running in the Studio is not one this window opens on.
|
||||
// An answer still being written in a closed window: the floating button breathes until it is opened. Only a
|
||||
// trip's chat counts: an answer still running in the Studio is not one this window opens on.
|
||||
const busy = useAssistantStore((s) =>
|
||||
!s.window.open || s.window.minimized
|
||||
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind !== 'journey')
|
||||
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind === 'trip')
|
||||
: false
|
||||
);
|
||||
const keys = useAssistantShortcutKeys();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Fragment, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
@@ -16,7 +16,7 @@ import AssistantShareDialog from './AssistantShareDialog';
|
||||
import AssistantSources from './AssistantSources';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||
import { type MessageBlock, messageBlocks, tailStart } from './assistantParts';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import AssistantCardBlock from './cards/AssistantCardBlock';
|
||||
import { useAssistantShare } from './useAssistantShare';
|
||||
@@ -108,28 +108,6 @@ function Question({ message }: { message: StoredMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One block of an answer, in the shape its kind takes. */
|
||||
function AnswerBlock({
|
||||
block,
|
||||
message,
|
||||
writing,
|
||||
streaming,
|
||||
}: {
|
||||
block: MessageBlock;
|
||||
message: StoredMessage;
|
||||
writing: boolean;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
if (block.kind === 'text') return <AssistantMarkdown text={block.text} writing={streaming} />;
|
||||
if (block.kind === 'steps') return <AssistantToolSteps steps={block.steps} />;
|
||||
if (block.kind === 'places') return <AssistantPlaceStrip found={block.found} message={message} />;
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources sources={block.sources} />;
|
||||
if (block.kind === 'remembered') return <AssistantRemembered note={block.note} live={writing} />;
|
||||
if (block.kind === 'cards') return <AssistantCardBlock block={block} message={message} />;
|
||||
return <AssistantChangeCard items={block.items} />;
|
||||
}
|
||||
|
||||
function Answer({ message }: { message: StoredMessage }) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
@@ -141,16 +119,25 @@ function Answer({ message }: { message: StoredMessage }) {
|
||||
const share = useAssistantShare(tripId, message, writing);
|
||||
const last = blocks[blocks.length - 1];
|
||||
const hasText = blocks.some((b) => b.kind === 'text');
|
||||
const tail = tailStart(blocks);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
|
||||
{blocks.map((block, i) => (
|
||||
<Fragment key={block.key}>
|
||||
{i === tail && i > 0 && <span data-assistant-tail aria-hidden="true" className="-mb-2.5 block h-0" />}
|
||||
<AnswerBlock block={block} message={message} writing={writing} streaming={writing && block === last} />
|
||||
</Fragment>
|
||||
))}
|
||||
{blocks.map((block) => {
|
||||
if (block.kind === 'text') {
|
||||
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
|
||||
}
|
||||
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
|
||||
if (block.kind === 'places') {
|
||||
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||
}
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
|
||||
if (block.kind === 'remembered') {
|
||||
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
|
||||
}
|
||||
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
|
||||
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||
})}
|
||||
{/* Nothing below says the answer goes on: no words being written, no step running. */}
|
||||
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
|
||||
<p role="status" className="m-0 text-caption">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AssistantFoundPlace } from '@trek/shared';
|
||||
import { Check, MapPin, Plus, Route, Star } from 'lucide-react';
|
||||
import { Check, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { createElement, type ReactNode, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -25,24 +25,6 @@ function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip:
|
||||
);
|
||||
}
|
||||
|
||||
/** "+ Tour": takes the place into the saved tour open in the planner as a stop, held as a change in the chat. */
|
||||
function TourButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!strip.tour) return null;
|
||||
return (
|
||||
<Tooltip label={t('assistant.place.addToTourHint', { name: strip.tour.name })} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => strip.addToTour(place)}
|
||||
className={`${ACTION} ${tall ? 'h-10' : 'h-7'} min-w-0 px-2`}
|
||||
>
|
||||
<Route size={12} aria-hidden="true" className="flex-shrink-0" />
|
||||
<span className="truncate">{t('assistant.place.addToTour')}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -203,9 +185,8 @@ export default function AssistantPlaceCard({
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="mt-auto flex min-w-0 gap-1.5 px-3 pb-3 pt-2.5">
|
||||
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
|
||||
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
|
||||
<TourButton place={place} strip={strip} tall={tall} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,6 @@ import { fetchPhoto } from '../../services/photoService';
|
||||
import { assistantChatKey } from '../../store/assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { setOpenTour } from '../../store/openTourStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { setForcedOffline } from '../../sync/networkMode';
|
||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||
@@ -106,7 +105,6 @@ afterEach(() => {
|
||||
useAuthStore.setState(AUTH, true);
|
||||
globalThis.IntersectionObserver = RealObserver;
|
||||
setForcedOffline(false);
|
||||
setOpenTour(null);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -240,22 +238,3 @@ describe('AssistantPlaceStrip', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('"+ Tour" on a found place (P6)', () => {
|
||||
it('FE-AI-PLACES-011: shows only while a saved tour of this trip is open, and holds the place as its stop', () => {
|
||||
seed();
|
||||
const insertFoundPlace = vi.fn(async () => 'held' as const);
|
||||
useAssistantStore.setState({ insertFoundPlace });
|
||||
setOpenTour({ tripId: TRIP, placeId: 64, name: 'Herzogstand' });
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to tour' }));
|
||||
expect(insertFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 64);
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-012: a tour open on another trip, or a traveller who may not edit, gets no "+ Tour"', () => {
|
||||
seed();
|
||||
setOpenTour({ tripId: TRIP + 1, placeId: 64, name: 'Elsewhere' });
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.queryByRole('button', { name: 'Add to tour' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ export default function AssistantSurface({
|
||||
onFocusDay,
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
}: AssistantSurfaceProps) {
|
||||
const runtime = useAssistantRuntime(tripId);
|
||||
// The phone is always compact; a window is what its width says.
|
||||
@@ -42,9 +41,8 @@ export default function AssistantSurface({
|
||||
onFocusDay,
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
}),
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion]
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AuiIf, ThreadPrimitive } from '@assistant-ui/react';
|
||||
import { ArrowDown, FileUp, MapPin } from 'lucide-react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
@@ -15,15 +15,13 @@ import { useAssistantAttach } from './useAssistantAttach';
|
||||
import { useFollowSelectedDay } from './useAssistantFocus';
|
||||
import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
import { useRevealAnswerTail } from './useRevealAnswerTail';
|
||||
|
||||
/**
|
||||
* The chat itself: the messages, and under them the line that says what went
|
||||
* wrong, the privacy note and the composer, held at the bottom while the
|
||||
* messages scroll. A new question moves up to the top of the view, so its
|
||||
* answer reads from its first line instead of chasing the newest one, and once
|
||||
* the answer is written its end comes into view. A booking file or a place of
|
||||
* the trip can be dropped anywhere on it.
|
||||
* answer reads from its first line instead of chasing the newest one. A
|
||||
* booking file or a place of the trip can be dropped anywhere on it.
|
||||
*/
|
||||
export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -33,8 +31,6 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||
const { suggestions, canEdit } = useAssistantSuggestions(tripId);
|
||||
useFollowSelectedDay(tripId);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
useRevealAnswerTail(viewport, isRunning);
|
||||
|
||||
const phone = variant === 'fullscreen';
|
||||
const attach = useAssistantAttach(tripId);
|
||||
@@ -56,11 +52,7 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ThreadPrimitive.Viewport
|
||||
ref={viewport}
|
||||
turnAnchor="top"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"
|
||||
>
|
||||
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
|
||||
{mentionable && (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ChevronRight, Luggage } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import EmptyState from '../shared/EmptyState';
|
||||
@@ -7,19 +6,12 @@ import { Spinner } from '../shared/Spinner';
|
||||
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
|
||||
|
||||
/**
|
||||
* The user's trips to go on with in one trip's own chat, from the home chat's
|
||||
* head: that chat, its history and its changes belong to the trip. The
|
||||
* running and the next trip lead. The list opens before the trips are there,
|
||||
* so the focus goes onto the first one once they come.
|
||||
* Outside a trip the assistant first asks which one: the chat, its history
|
||||
* and its changes all belong to one trip. The running and the next trip lead.
|
||||
*/
|
||||
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const trips = useAssistantTripPicker();
|
||||
const first = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
first.current?.focus({ preventScroll: true });
|
||||
}, [trips]);
|
||||
|
||||
if (trips === null) {
|
||||
return (
|
||||
@@ -39,10 +31,9 @@ export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerT
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
|
||||
<ul className="trek-stagger m-0 flex list-none flex-col gap-1 p-0">
|
||||
{trips.map((trip, i) => (
|
||||
{trips.map((trip) => (
|
||||
<li key={trip.id}>
|
||||
<button
|
||||
ref={i === 0 ? first : undefined}
|
||||
type="button"
|
||||
onClick={() => onPick(trip)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
|
||||
|
||||
@@ -28,15 +28,20 @@ export default function AssistantWelcome({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
// The Studio's chat is about a book, not a plan; the home chat about all of the user's travels.
|
||||
const scope = useAssistantScope().kind;
|
||||
const book = scope === 'journey';
|
||||
// The Studio's chat is about a book, not a plan.
|
||||
const book = useAssistantScope().kind === 'journey';
|
||||
const hints = useAssistantHints(tripId, { withStays: true });
|
||||
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
|
||||
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
|
||||
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
|
||||
<EmptyState
|
||||
scene="chat"
|
||||
size={72}
|
||||
compact
|
||||
surface="var(--bg-elevated)"
|
||||
title={book ? t('journey.studio.assistant.welcome') : t('assistant.empty.title')}
|
||||
/>
|
||||
{!canEdit && (
|
||||
<p className="m-0 max-w-xs text-center text-caption text-content-muted">
|
||||
{book ? t('journey.studio.assistant.readOnly') : t('assistant.empty.readOnly')}
|
||||
@@ -48,12 +53,6 @@ export default function AssistantWelcome({
|
||||
);
|
||||
}
|
||||
|
||||
/** The question over the empty chat, by what the chat is about. */
|
||||
function welcomeTitle(scope: 'trip' | 'journey' | 'home', t: (key: string) => string): string {
|
||||
if (scope === 'journey') return t('journey.studio.assistant.welcome');
|
||||
return scope === 'home' ? t('assistant.home.welcome') : t('assistant.empty.title');
|
||||
}
|
||||
|
||||
export function AssistantSuggestions({
|
||||
suggestions,
|
||||
layout,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* FE-AI-WIN-001 to FE-AI-WIN-016
|
||||
* FE-AI-WIN-001 to FE-AI-WIN-012
|
||||
*
|
||||
* The desktop window around the chat: when it shows at all, the button it
|
||||
* opens from, the home chat outside a trip with its way into one trip's chat
|
||||
* and back, closing that keeps the chat, and
|
||||
* opens from, the trip picker outside a trip, closing that keeps the chat, and
|
||||
* Escape, which stops an answer before it closes anything (and leaves the
|
||||
* header's menu to itself), and where the focus goes on the way; a trip's head
|
||||
* offers the notes its turns read. The geometry is
|
||||
@@ -37,14 +36,12 @@ vi.mock('./AssistantSurfaceLazy', () => ({
|
||||
density,
|
||||
onFocusDay,
|
||||
onOpen,
|
||||
onOpenTripChat,
|
||||
}: {
|
||||
tripId: number;
|
||||
tripTitle: string;
|
||||
density?: string;
|
||||
onFocusDay?: (dayId: number) => void;
|
||||
onOpen?: (target: EntityTarget) => void;
|
||||
onOpenTripChat?: (trip: { id: number; title: string }, question: string | null) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<p>
|
||||
@@ -69,11 +66,6 @@ vi.mock('./AssistantSurfaceLazy', () => ({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onOpenTripChat && (
|
||||
<button type="button" onClick={() => onOpenTripChat({ id: 9, title: 'Lisbon' }, 'What is missing?')}>
|
||||
go on in Lisbon
|
||||
</button>
|
||||
)}
|
||||
<textarea aria-label="composer" />
|
||||
</div>
|
||||
),
|
||||
@@ -144,36 +136,12 @@ describe('AssistantWindow', () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-004: outside a trip it opens on the home chat, whose head opens one trip chat and goes back', async () => {
|
||||
it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={null} />);
|
||||
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
|
||||
expect(resume).toHaveBeenCalledWith(0);
|
||||
// The home chat has no trip notes.
|
||||
expect(screen.queryByRole('button', { name: 'Trip notes' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Chat about one trip' }));
|
||||
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
|
||||
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to all trips' }));
|
||||
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FE-AI-WIN-013: the home chat's card goes on in the trip's chat with the question waiting there", () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={null} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'go on in Lisbon' }));
|
||||
expect(screen.getByText('chat 9 Lisbon')).toBeInTheDocument();
|
||||
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('What is missing?');
|
||||
// In a trip's chat there is no card to go on from.
|
||||
expect(screen.queryByRole('button', { name: 'go on in Lisbon' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-014: minimized outside a trip, the round button brings the home chat back', () => {
|
||||
seed({ open: true, minimized: true });
|
||||
render(<AssistantWindow trip={null} />);
|
||||
expect(screen.getByText('chat 0 Your travels')).not.toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
expect(useAssistantStore.getState().window.minimized).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-005: closed while writing, the chat stays mounted and the round button breathes until it is opened', () => {
|
||||
@@ -238,34 +206,6 @@ describe('AssistantWindow', () => {
|
||||
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||
});
|
||||
|
||||
it("FE-AI-WIN-015: the header's menu says whether it is open, and Escape hands the focus back to its button", async () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={TRIP} />);
|
||||
const more = screen.getByRole('button', { name: 'More options' });
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(more);
|
||||
expect(more).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.keyDown(await screen.findByRole('menu'), { key: 'Escape' });
|
||||
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());
|
||||
expect(more).toHaveFocus();
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false');
|
||||
// From the button the next Escape is the window's again.
|
||||
fireEvent.keyDown(more, { key: 'Escape' });
|
||||
expect(useAssistantStore.getState().window.open).toBe(false);
|
||||
});
|
||||
|
||||
it("FE-AI-WIN-016: the home chat's trip list takes the focus onto the first trip once the trips are there", async () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={null} />);
|
||||
const button = screen.getByRole('button', { name: 'Chat about one trip' });
|
||||
fireEvent.click(button);
|
||||
// The list's own row, not the chat's "go on in Lisbon" beside it.
|
||||
const lisbon = await screen.findByRole('button', { name: /^Lisbon/ });
|
||||
await waitFor(() => expect(lisbon).toHaveFocus());
|
||||
fireEvent.keyDown(lisbon, { key: 'Escape' });
|
||||
expect(button).toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-010: tells the map where it sits, is compact at 400 px wide, and picks days on screen', () => {
|
||||
seed({ open: true, minimized: false });
|
||||
const onFocusDay = vi.fn();
|
||||
@@ -298,10 +238,6 @@ describe('AssistantWindow', () => {
|
||||
<Location />
|
||||
</>
|
||||
);
|
||||
// From the home chat only another page opens.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open page' }));
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/atlas');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Chat about one trip' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Lisbon/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open tab' }));
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/trips/9?tab=finanzplan&expense=3');
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import './assistant.css';
|
||||
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import type { WindowRect } from '../../hooks/useDragResize';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import AssistantFrameEdges from './AssistantFrameEdges';
|
||||
import AssistantHeader from './AssistantHeader';
|
||||
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
|
||||
import AssistantSurfaceLazy from './AssistantSurfaceLazy';
|
||||
import AssistantTripPicker from './AssistantTripPicker';
|
||||
import { FRAME_CLASS, useAssistantFrame } from './assistantFrame';
|
||||
import { plannerSearchOf } from './assistantShowOnMap';
|
||||
import type { AssistantChatTrip } from './assistantViewContext';
|
||||
import type { EntityTarget } from './cards/cardTypes';
|
||||
import { useAssistantHome } from './useAssistantHome';
|
||||
import { useAssistantHotkey } from './useAssistantHotkey';
|
||||
import { useAssistantThread } from './useAssistantThread';
|
||||
|
||||
@@ -31,18 +30,17 @@ import { useAssistantThread } from './useAssistantThread';
|
||||
* an answer is still being written. Escape stops an answer first, then
|
||||
* closes, and the focus goes back to where it was.
|
||||
*
|
||||
* Outside a trip it opens on the home chat, which knows all of the user's
|
||||
* trips; its head lists them to go on in one trip's own chat, and a trip's
|
||||
* chat opened that way has the way back home in its head.
|
||||
*
|
||||
* Mounted once for the whole app; it shows nothing until the assistant has a
|
||||
* connection this user can use.
|
||||
*/
|
||||
|
||||
type WindowTrip = AssistantChatTrip;
|
||||
interface WindowTrip {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface AssistantWindowProps {
|
||||
/** The trip on screen; null outside a trip, where the window opens on the home chat. */
|
||||
/** The trip on screen; null outside a trip, where the window asks which one first. */
|
||||
trip: WindowTrip | null;
|
||||
/** Opens what a card or an applied change points at, from the trip on screen. */
|
||||
onOpen?: (target: EntityTarget) => void;
|
||||
@@ -72,16 +70,12 @@ function ChatWindow({
|
||||
onOpen,
|
||||
onFocusDay,
|
||||
onRevealMap,
|
||||
onOpenTripChat,
|
||||
tripSwitch,
|
||||
}: {
|
||||
trip: WindowTrip;
|
||||
hidden: boolean;
|
||||
onOpen?: (target: EntityTarget) => void;
|
||||
onFocusDay?: (dayId: number) => void;
|
||||
onRevealMap?: () => void;
|
||||
onOpenTripChat?: (trip: WindowTrip, question: string | null) => void;
|
||||
tripSwitch?: Parameters<typeof AssistantHeader>[0]['tripSwitch'];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const thread = useAssistantThread(trip.id);
|
||||
@@ -156,7 +150,6 @@ function ChatWindow({
|
||||
thread={thread}
|
||||
tripTitle={trip.title}
|
||||
dragProps={frame.handleProps}
|
||||
tripSwitch={tripSwitch}
|
||||
// Closing only hides the window: an answer being written keeps coming in,
|
||||
// and the floating button says so until it is opened again.
|
||||
onClose={() => setMinimized(true)}
|
||||
@@ -169,7 +162,6 @@ function ChatWindow({
|
||||
onOpen={onOpen}
|
||||
onFocusDay={onFocusDay}
|
||||
onRevealMap={onRevealMap}
|
||||
onOpenTripChat={onOpenTripChat}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -185,21 +177,19 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
const toggle = useAssistantStore((s) => s.toggle);
|
||||
const [picked, setPicked] = useState<WindowTrip | null>(null);
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
const home = useAssistantHome(setPicked);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
useAssistantHotkey(available, toggle);
|
||||
|
||||
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
|
||||
// A trip picked outside the planner is only for this opening; the next one asks again.
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (!open) setPicked(null);
|
||||
}
|
||||
|
||||
const current = trip ?? picked ?? home.chat;
|
||||
const atHome = current.id === HOME_CHAT_KEY;
|
||||
const current = trip ?? picked;
|
||||
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
|
||||
const openPicked = useCallback(
|
||||
(target: EntityTarget) => {
|
||||
@@ -208,14 +198,6 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
},
|
||||
[navigate, picked]
|
||||
);
|
||||
// The home chat has no trip to show anything in; another page it simply opens.
|
||||
const openFromHome = useCallback(
|
||||
(target: EntityTarget) => {
|
||||
if (target.kind === 'page') void navigate(target.path);
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
const goHome = useCallback(() => setPicked(null), []);
|
||||
// Another page leaves the trip: the chat stays on it, as if it had been picked.
|
||||
const openOnScreen = useCallback(
|
||||
(target: EntityTarget) => {
|
||||
@@ -227,27 +209,52 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
|
||||
|
||||
if (!available) return null;
|
||||
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
|
||||
if (!current) return <PickerWindow onPick={setPicked} />;
|
||||
|
||||
const onScreen = !!trip && current.id === trip.id;
|
||||
let openTarget = onScreen ? onOpen && openOnScreen : openPicked;
|
||||
if (atHome) openTarget = openFromHome;
|
||||
let tripSwitch: { onPick?: (trip: WindowTrip) => void; onHome?: () => void } | undefined;
|
||||
if (atHome) tripSwitch = { onPick: setPicked };
|
||||
else if (!onScreen) tripSwitch = { onHome: goHome };
|
||||
return (
|
||||
<>
|
||||
<ChatWindow
|
||||
trip={current}
|
||||
hidden={minimized}
|
||||
onOpen={openTarget}
|
||||
onOpen={onScreen ? onOpen && openOnScreen : openPicked}
|
||||
// Only the planner on screen has days to select and a map to bring back.
|
||||
onFocusDay={onScreen ? onFocusDay : undefined}
|
||||
onRevealMap={onScreen ? onRevealMap : undefined}
|
||||
onOpenTripChat={atHome ? home.openTripChat : undefined}
|
||||
tripSwitch={tripSwitch}
|
||||
/>
|
||||
{/* Hidden while an answer may still come in: the round button brings it back, on any page. */}
|
||||
{minimized && <AssistantLauncher variant="floating" tripId={trip?.id ?? null} focusOnMount />}
|
||||
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerWindow({ onPick }: { onPick: (trip: WindowTrip) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const close = useAssistantStore((s) => s.close);
|
||||
return (
|
||||
<section
|
||||
role="dialog"
|
||||
aria-labelledby="assistant-picker-title"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && !e.defaultPrevented) close();
|
||||
}}
|
||||
className="trek-assistant-pop fixed z-[var(--z-nav)] flex max-h-[min(32rem,calc(100vh-6rem))] w-[22rem] flex-col overflow-hidden rounded-xl border border-edge-faint bg-surface-elevated shadow-[var(--sidebar-shadow)] backdrop-blur-2xl backdrop-saturate-150"
|
||||
style={{ insetInlineEnd: LAUNCHER_INSET, bottom: LAUNCHER_INSET, transformOrigin: 'bottom' }}
|
||||
>
|
||||
<header className="flex h-10 flex-shrink-0 items-center gap-2 border-b border-edge-faint pe-1.5 ps-3">
|
||||
<Sparkles size={14} aria-hidden="true" className="flex-shrink-0 text-content-muted" />
|
||||
<h2 id="assistant-picker-title" className="m-0 flex-1 truncate text-body font-semibold text-content">
|
||||
{t('assistant.picker.title')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label={t('common.close')}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-content-muted hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<AssistantTripPicker onPick={({ id, title }) => onPick({ id, title })} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,6 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
|
||||
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
|
||||
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
|
||||
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
|
||||
no_tracks_found: { messageKey: 'assistant.error.noTracksFound', action: null },
|
||||
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
|
||||
generic: {
|
||||
messageKey: 'assistant.error.generic',
|
||||
@@ -225,17 +224,6 @@ const PHASE_SUGGESTIONS: Record<TripPhase, AssistantSuggestion[]> = {
|
||||
|
||||
const MAX_SUGGESTIONS = 4;
|
||||
|
||||
/**
|
||||
* What the empty home chat offers, outside any one trip: questions across the
|
||||
* user's trips and about the parts of TREK that belong to none of them.
|
||||
*/
|
||||
export const HOME_SUGGESTIONS: readonly AssistantSuggestion[] = [
|
||||
{ id: 'homeNext', labelKey: 'assistant.suggest.homeNext' },
|
||||
{ id: 'homeCompare', labelKey: 'assistant.suggest.homeCompare' },
|
||||
{ id: 'homeAtlas', labelKey: 'assistant.suggest.homeAtlas' },
|
||||
{ id: 'homeVacay', labelKey: 'assistant.suggest.homeVacay' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The questions the empty chat offers: about the place or day in focus first,
|
||||
* then what fits the trip's phase. The label is also the prompt, so a tapped
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface ChangeItem {
|
||||
/** The answer cards of one group, side by side. */
|
||||
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
|
||||
|
||||
export type MessageBlock =
|
||||
type MessageBlock =
|
||||
| { kind: 'text'; key: string; text: string }
|
||||
| { kind: 'steps'; key: string; steps: ToolStep[] }
|
||||
| { kind: 'change'; key: string; items: ChangeItem[] }
|
||||
@@ -127,10 +127,6 @@ function namedFirst(places: readonly AssistantFoundPlace[], text: string): Assis
|
||||
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
|
||||
'propose_hike',
|
||||
'create_hike',
|
||||
'propose_tour',
|
||||
'create_tour',
|
||||
'create_paddle_tour',
|
||||
'create_ski_tour',
|
||||
'update_tour',
|
||||
'propose_city_walk',
|
||||
'create_city_walk',
|
||||
@@ -138,9 +134,6 @@ const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
|
||||
'plan_ski_route',
|
||||
'prepare_tour_change',
|
||||
'save_tour_change',
|
||||
'propose_round_hike',
|
||||
'propose_hike_leg_way',
|
||||
'import_wanderer_trail',
|
||||
]);
|
||||
|
||||
/** The tool that keeps a note in the trip notes; kept at once, without a card. */
|
||||
@@ -462,16 +455,4 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
difficulty: { labelKey: 'assistant.change.label.difficulty' },
|
||||
track: { labelKey: 'assistant.change.label.track' },
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
breaks: { labelKey: 'assistant.change.label.breaks' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Where an answer's closing words and changes begin: the block after its last
|
||||
* step, strip or card. That end is what the thread brings into view once the
|
||||
* answer is written.
|
||||
*/
|
||||
export function tailStart(blocks: MessageBlock[]): number {
|
||||
let i = blocks.length;
|
||||
while (i > 0 && (blocks[i - 1].kind === 'text' || blocks[i - 1].kind === 'change')) i -= 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
@@ -34,17 +34,6 @@ export interface AssistantView {
|
||||
* version the trial ran on.
|
||||
*/
|
||||
bookVersion?: number | null;
|
||||
/**
|
||||
* Goes on in a trip's own chat with a question waiting there, from the home
|
||||
* chat's card that offers it; left out where no chat can be switched to.
|
||||
*/
|
||||
onOpenTripChat?: (trip: AssistantChatTrip, question: string | null) => void;
|
||||
}
|
||||
|
||||
/** A trip as the chat switches to it: its id, which is its chat's key, and its title for the head. */
|
||||
export interface AssistantChatTrip {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const AssistantViewContext = createContext<AssistantView | null>(null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-017
|
||||
* FE-AI-BOOKPAGE-010 to FE-AI-BOOKPAGE-016
|
||||
*
|
||||
* The pure side of the book's page card: a page named as the Studio names
|
||||
* it, a layout and a finding by their words, the findings worth a look, the
|
||||
@@ -8,7 +8,7 @@
|
||||
* the card carries it and the book is still at the trial's version), and
|
||||
* where an applied change opens the Studio.
|
||||
*/
|
||||
import { BOOK_FINDING_CODES, blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
|
||||
import { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -67,9 +67,6 @@ describe('bookCardModel', () => {
|
||||
it('FE-AI-BOOKPAGE-011: a layout is named by its template, its grid, its own name or as laid out again', () => {
|
||||
expect(layoutLabel({ kind: 'template', id: 'two-up', name: null }, t)).toBe('journey.studio.tpl.twoUp');
|
||||
expect(layoutLabel({ kind: 'template', id: 'cover-full', name: null }, t)).toBe('journey.studio.tpl.coverFull');
|
||||
expect(layoutLabel({ kind: 'template', id: 'postcard-houses', name: null }, t)).toBe(
|
||||
'studio.templates.name.postcardHouses'
|
||||
);
|
||||
expect(layoutLabel({ kind: 'grid', id: 'two-across', name: null }, t)).toBe('assistant.card.book.grid {"count":2}');
|
||||
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: 'Mine' }, t)).toBe('Mine');
|
||||
expect(layoutLabel({ kind: 'saved', id: 'lay-1', name: null }, t)).toBe('lay-1');
|
||||
@@ -95,15 +92,6 @@ describe('bookCardModel', () => {
|
||||
expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi');
|
||||
});
|
||||
|
||||
it('FE-AI-BOOKPAGE-017: every finding the checks answer with has its words, the art checks among them', () => {
|
||||
for (const code of BOOK_FINDING_CODES) {
|
||||
expect(findingLabel({ code, severity: 'info' }, t)).toBe(`assistant.card.book.finding.${code}`);
|
||||
}
|
||||
for (const code of ['flat_cover', 'weak_hierarchy', 'off_theme', 'energy_mismatch'] as const) {
|
||||
expect(BOOK_FINDING_CODES).toContain(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => {
|
||||
const doc = book();
|
||||
const [edit] = pageViewOf(card(), doc, 'pending');
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type BookSpread,
|
||||
findBookGrid,
|
||||
findBookTemplate,
|
||||
findDecoratedTemplate,
|
||||
setBookCover,
|
||||
} from '@trek/shared';
|
||||
|
||||
@@ -58,15 +57,6 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
|
||||
font_mix: { labelKey: 'assistant.card.book.finding.font_mix' },
|
||||
palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' },
|
||||
repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' },
|
||||
raster_effect: { labelKey: 'assistant.card.book.finding.raster_effect' },
|
||||
hairline: { labelKey: 'assistant.card.book.finding.hairline' },
|
||||
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
|
||||
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
|
||||
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
|
||||
flat_cover: { labelKey: 'assistant.card.book.finding.flat_cover' },
|
||||
weak_hierarchy: { labelKey: 'assistant.card.book.finding.weak_hierarchy' },
|
||||
off_theme: { labelKey: 'assistant.card.book.finding.off_theme' },
|
||||
energy_mismatch: { labelKey: 'assistant.card.book.finding.energy_mismatch' },
|
||||
};
|
||||
|
||||
/** A page as the Studio names it: the cover, the first page, double page 3. */
|
||||
@@ -92,7 +82,7 @@ export function layoutLabel(layout: BookCardLayout | null, t: Translate): string
|
||||
const grid = findBookGrid(layout.id);
|
||||
return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null;
|
||||
}
|
||||
const template = findBookTemplate(layout.id) ?? findDecoratedTemplate(layout.id);
|
||||
const template = findBookTemplate(layout.id);
|
||||
return template ? t(template.labelKey) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ export const BOOK_LOOK = {
|
||||
// The server's own read: it waits for the Studio to draw the page, and says it looks at it.
|
||||
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
|
||||
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
|
||||
list_book_assets: { stepKey: 'assistant.step.bookAssets' },
|
||||
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
|
||||
},
|
||||
entities: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookmarkMinus, BookmarkPlus, Copy, Palette, Ruler, Sparkles } from 'lucide-react';
|
||||
import { BookmarkMinus, BookmarkPlus, Copy, Ruler, Sparkles } from 'lucide-react';
|
||||
|
||||
import type { PackLook } from '../cardTypes';
|
||||
|
||||
@@ -19,7 +19,6 @@ export const BOOK_DESIGN_LOOK = {
|
||||
verbs: {
|
||||
designBook: { Icon: Sparkles, tone: 'info', labelKey: 'assistant.change.verb.designBook' },
|
||||
setBookSetup: { Icon: Ruler, tone: 'info', labelKey: 'assistant.change.verb.setBookSetup' },
|
||||
setBookTheme: { Icon: Palette, tone: 'info', labelKey: 'assistant.change.verb.setBookTheme' },
|
||||
duplicateBookPage: { Icon: Copy, tone: 'success', labelKey: 'assistant.change.verb.duplicateBookPage' },
|
||||
saveBookLayout: { Icon: BookmarkPlus, tone: 'success', labelKey: 'assistant.change.verb.saveBookLayout' },
|
||||
removeBookLayout: { Icon: BookmarkMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeBookLayout' },
|
||||
|
||||