mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ae607f54 | |||
| 338548bf7f | |||
| a94464a953 | |||
| 88e6f332d3 | |||
| d07b8ba56e | |||
| e9f7d5e77b | |||
| 9a7c520106 | |||
| 5ccd4674ac | |||
| d1b8cb20a3 | |||
| dd2431db97 | |||
| 746f53ff7e | |||
| 7f9a4da52a | |||
| 8043b7952d | |||
| e198392060 | |||
| deea669e28 | |||
| d6b624e400 | |||
| 263d650460 | |||
| a3fc696510 | |||
| 9f6f62c6ae |
@@ -69,6 +69,15 @@
|
||||
"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"],
|
||||
|
||||
+4
-3
@@ -67,6 +67,7 @@ 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
|
||||
@@ -410,10 +411,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. */}
|
||||
{!hideAuthedWidgets && isAuthenticated && !isPhone && (
|
||||
{/* The phone has its own assistant screen inside the trip, and the home chat's outside it. */}
|
||||
{!hideAuthedWidgets && isAuthenticated && (
|
||||
<ErrorBoundary boundaryId="widget:assistant" fallback={null}>
|
||||
<Suspense fallback={null}><AssistantHost /></Suspense>
|
||||
<Suspense fallback={null}>{isPhone ? <MAssistantHomeScreen /> : <AssistantHost />}</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
|
||||
@@ -165,4 +165,46 @@ 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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,14 +102,16 @@ const tripBase = (tripId: number) => `/trips/${tripId}/assistant`;
|
||||
|
||||
/**
|
||||
* What a conversation call is about: a trip's id (the planner's chat), or a
|
||||
* scope, which also reaches a journey's chat in TREK Studio. Threads, turns,
|
||||
* decisions, undo and stop exist under both; files, found places, proposals
|
||||
* and sharing belong to a trip and take its id.
|
||||
* scope, which also reaches a journey's chat in TREK Studio and the home chat
|
||||
* outside any one trip. Threads, turns, decisions, undo and stop exist under
|
||||
* all three; files, found places, proposals and sharing belong to a trip and
|
||||
* take its id.
|
||||
*/
|
||||
export type AssistantTarget = number | AssistantScope;
|
||||
|
||||
function scopeBase(target: AssistantTarget): string {
|
||||
if (typeof target === 'number') return tripBase(target);
|
||||
if (target.kind === 'home') return '/assistant';
|
||||
return target.kind === 'trip' ? tripBase(target.tripId) : `/journeys/${target.journeyId}/assistant`;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,12 @@ vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
|
||||
|
||||
const { default: AiSettingsDialog } = await import('./AiAdminConfig');
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
||||
const SETTINGS: AiAdminSettings = {
|
||||
policy: 'instance_or_user',
|
||||
dailyTurns: 50,
|
||||
webSearch: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
};
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
scope: 'instance',
|
||||
@@ -102,6 +107,17 @@ describe('the connection and the assistant', () => {
|
||||
expect(screen.getByText('Keep chats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-001b: the web search is on as it ships and is switched off on the assistant tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
await user.click(await screen.findByRole('button', { name: 'AI Assistant' }));
|
||||
const toggle = screen.getByRole('button', { name: 'Web search' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText(/through the search of the AI provider it talks to/)).toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
expect(repo.saveAdminSettings).toHaveBeenCalledWith({ ...SETTINGS, webSearch: false });
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-ADMIN-002: "Everyone uses their own" drops the connection and the limit', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Check, Loader2, Sparkles } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import AiConnectPanel, { ProviderMark } from '../../Settings/ai/AiConnectPanel';
|
||||
import { CONNECTION_STATUS } from '../../Settings/ai/aiConnectModel';
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
@@ -136,7 +137,7 @@ function InstanceConnection({ A }: { A: AiAdmin }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The assistant's own limits: turns a day while the instance pays, and how long chats are kept. */
|
||||
/** The assistant's own limits: turns a day while the instance pays, how long chats are kept, whether it searches the web. */
|
||||
function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
@@ -170,6 +171,19 @@ function AssistantLimits({ A }: { A: AiAdmin }) {
|
||||
size="sm"
|
||||
/>
|
||||
</EditorField>
|
||||
{A.settings && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-body font-medium text-content">{t('assistant.admin.webSearch')}</span>
|
||||
<ToggleSwitch
|
||||
on={A.settings.webSearch}
|
||||
onToggle={() => A.setWebSearch(!A.settings?.webSearch)}
|
||||
label={t('assistant.admin.webSearch')}
|
||||
/>
|
||||
</div>
|
||||
<SettingsHint>{t('assistant.admin.webSearchHint')}</SettingsHint>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,12 @@ const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(TranslationProvider, null, children)
|
||||
);
|
||||
|
||||
const SETTINGS: AiAdminSettings = { policy: 'instance_or_user', dailyTurns: 50, retention: { mode: 'days', days: 90 } };
|
||||
const SETTINGS: AiAdminSettings = {
|
||||
policy: 'instance_or_user',
|
||||
dailyTurns: 50,
|
||||
webSearch: true,
|
||||
retention: { mode: 'days', days: 90 },
|
||||
};
|
||||
const NONE: AiAdminConnectionResponse = { connection: null, prefill: null };
|
||||
const LIVE: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
|
||||
@@ -40,7 +40,7 @@ function retentionOf(value: string): AiRetention {
|
||||
return value === FOREVER ? { mode: 'forever' } : { mode: 'days', days: Number(value) };
|
||||
}
|
||||
|
||||
/** Policy, daily limit and retention: each change is saved at once and taken back when the server refuses it. */
|
||||
/** Policy, daily limit, retention and web search: each change is saved at once and taken back when the server refuses it. */
|
||||
function useAdminSettings(onSaved: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
@@ -92,6 +92,7 @@ function useAdminSettings(onSaved: () => void) {
|
||||
{ value: FOREVER, label: t('assistant.admin.retentionForever') },
|
||||
],
|
||||
setRetention: (value: string) => void save({ retention: retentionOf(value) }),
|
||||
setWebSearch: (webSearch: boolean) => void save({ webSearch }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Check, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
|
||||
import { Check, CheckCheck, ListChecks, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { blockKeyOf } from '../../store/assistantMapModel';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { DialogButton, NEUTRAL_TINT } from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||
@@ -13,6 +14,8 @@ import AssistantWeekOverview from './AssistantWeekOverview';
|
||||
import { type ChangeItem, undoLeftMs } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { ChangeRows } from './cards/ChangeCardSlots';
|
||||
import CardHeader from './cards/parts/CardHeader';
|
||||
import { CardFoot } from './cards/parts/CardSection';
|
||||
import { ICON_BUTTON } from './cards/parts/assistantTones';
|
||||
import { useChangeExclusions } from './cards/useChangeExclusions';
|
||||
import { changePoints, type DayChange } from './dayAfterModel';
|
||||
@@ -60,6 +63,9 @@ function undoableNow(item: ChangeItem, undone: Readonly<Record<string, true>>, n
|
||||
);
|
||||
}
|
||||
|
||||
/** The band of a card whose changes are all decided: the green of a done booking. */
|
||||
const DONE_TINT = 'color-mix(in srgb, var(--success) 9%, transparent)';
|
||||
|
||||
/** The waiting changes the user left on that touch the day plan. */
|
||||
function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): DayChange[] {
|
||||
return items.flatMap((i) =>
|
||||
@@ -69,17 +75,18 @@ function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's head: what the card is, what can be done with it as a whole, and how many changes it holds. */
|
||||
/** The change card's band: tinted green once everything in it is decided, what it holds and its actions. */
|
||||
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-1 flex min-h-7 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-content-secondary">
|
||||
{t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||
</span>
|
||||
{actions}
|
||||
<span className="text-caption tabular-nums text-content-faint">{count}</span>
|
||||
</div>
|
||||
<CardHeader
|
||||
icon={open ? ListChecks : CheckCheck}
|
||||
tone={open ? 'info' : 'success'}
|
||||
tint={open ? NEUTRAL_TINT : DONE_TINT}
|
||||
title={t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||
count={count}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,7 +231,7 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
|
||||
const open = pending.length > 0;
|
||||
return (
|
||||
<section className="trek-popover-enter rounded-xl border border-edge-faint bg-surface-card px-3 pb-2 pt-2.5 shadow-card">
|
||||
<section className="trek-popover-enter overflow-hidden rounded-[16px] border border-edge-faint bg-surface-card px-3.5 pb-3 pt-3 shadow-card">
|
||||
<CardHead
|
||||
open={open}
|
||||
count={items.length}
|
||||
@@ -257,25 +264,19 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
<p className="sr-only" role="status">
|
||||
{t('assistant.change.pending', { count: pending.length })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 border-t border-edge-faint pt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRunning}
|
||||
onClick={() => void submit(false)}
|
||||
className="rounded-lg px-3 py-1.5 text-body text-content-secondary hover:bg-surface-hover hover:text-content disabled:opacity-50"
|
||||
>
|
||||
<CardFoot>
|
||||
<DialogButton disabled={isRunning} onClick={() => void submit(false)}>
|
||||
{t('assistant.change.discard')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
disabled={isRunning || chosen === 0}
|
||||
onClick={() => void submit(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-body font-medium text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
icon={<Check size={14} aria-hidden="true" />}
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
|
||||
</button>
|
||||
</div>
|
||||
</DialogButton>
|
||||
</CardFoot>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -45,7 +46,11 @@ 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);
|
||||
const fallback = t('assistant.composer.placeholder', { trip: tripTitle });
|
||||
// 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 placeholder = useTypewriterPlaceholder(
|
||||
suggestions.map((s) => s.prompt),
|
||||
fallback,
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { Ellipsis, History, Lightbulb, LightbulbOff, NotebookPen, Plus, Settings, Trash2, X } from 'lucide-react';
|
||||
import {
|
||||
Ellipsis,
|
||||
History,
|
||||
House,
|
||||
Lightbulb,
|
||||
LightbulbOff,
|
||||
Luggage,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
Settings,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type HTMLAttributes, type MouseEvent, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
@@ -12,7 +24,9 @@ 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';
|
||||
@@ -29,6 +43,16 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +63,14 @@ interface AssistantHeaderProps {
|
||||
* keeps the conversation, and an answer being written goes on. The model is not picked here;
|
||||
* that lives in the settings. Notes are a trip's: a journey's chat in TREK Studio shows no notes button.
|
||||
*/
|
||||
export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dragProps }: AssistantHeaderProps) {
|
||||
export default function AssistantHeader({
|
||||
tripId,
|
||||
thread,
|
||||
tripTitle,
|
||||
onClose,
|
||||
dragProps,
|
||||
tripSwitch,
|
||||
}: AssistantHeaderProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const live = useAssistantLiveStep(tripId);
|
||||
const memory = useTripMemory(tripId);
|
||||
@@ -103,11 +134,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
<div className="min-w-0 flex-1">
|
||||
{thread.isRunning ? (
|
||||
// While it works, the line over the title says what it does right now.
|
||||
<p role="status" className="m-0 flex min-w-0 items-center gap-1.5 text-caption font-medium">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="trek-assistant-pulse h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent"
|
||||
/>
|
||||
<p role="status" className="m-0 flex min-w-0 items-center text-caption font-medium">
|
||||
<span className="trek-assistant-shimmer truncate">{live?.text ?? t('assistant.step.working')}</span>
|
||||
</p>
|
||||
) : (
|
||||
@@ -126,6 +153,7 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{tripSwitch && <TripSwitchButton {...tripSwitch} />}
|
||||
{tripChat && (
|
||||
<Tooltip label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}>
|
||||
<button
|
||||
@@ -222,3 +250,49 @@ export default function AssistantHeader({ tripId, thread, tripTitle, onClose, dr
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useLocation, useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import { rememberListsSubTab } from '../../hooks/useListsSubTab';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantWindow from './AssistantWindow';
|
||||
import { plannerSearchOf } from './assistantShowOnMap';
|
||||
@@ -16,13 +17,14 @@ 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; what a
|
||||
* 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
|
||||
* card or an applied change opens goes through the planner's links: "Show on
|
||||
* map" opens the planner on the change (`?place=`, `?day=`), the same way a
|
||||
* notification opens a place, a tab of the trip opens as `?tab=` (a list's
|
||||
* own tab remembered first), and another page is simply navigated to. A day
|
||||
* picked in the chat (its weather) selects that day the same way, and "On
|
||||
* map" on a card brings the planner's map back first.
|
||||
* map" on a card brings the planner's map back first from a tab without one.
|
||||
*/
|
||||
export default function AssistantHost() {
|
||||
const navigate = useNavigate();
|
||||
@@ -54,8 +56,12 @@ export default function AssistantHost() {
|
||||
[navigate, openPlanner, tripId]
|
||||
);
|
||||
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
|
||||
// "On map" from another tab of the planner (Tours, Costs, ...) first brings the map back.
|
||||
const revealMap = useCallback(() => openPlanner('?tab=plan'), [openPlanner]);
|
||||
// "On map" from a tab without a map (Costs, Lists, ...) first brings the plan's map back; the Tours tab
|
||||
// shows the route on its own map. A map on screen tells the assistant its view, which is how it is known.
|
||||
const revealMap = useCallback(() => {
|
||||
if (useAssistantStore.getState().mapView?.tripId === tripId) return;
|
||||
openPlanner('?tab=plan');
|
||||
}, [openPlanner, tripId]);
|
||||
|
||||
return <AssistantWindow trip={trip} onOpen={open} onFocusDay={focusDay} onRevealMap={revealMap} />;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,14 @@ 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 trip picker. Nothing shows until the
|
||||
* window; outside a trip it opens on the home chat. 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. Only a
|
||||
// trip's chat counts: 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. A
|
||||
// trip's chat and the home chat count: an answer still running in the Studio is not one this window opens on.
|
||||
const busy = useAssistantStore((s) =>
|
||||
!s.window.open || s.window.minimized
|
||||
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind === 'trip')
|
||||
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind !== 'journey')
|
||||
: false
|
||||
);
|
||||
const keys = useAssistantShortcutKeys();
|
||||
|
||||
@@ -11,7 +11,9 @@ import AssistantFollowUps from './AssistantFollowUps';
|
||||
import AssistantMarkdown from './AssistantMarkdown';
|
||||
import AssistantMentionText from './AssistantMentionText';
|
||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||
import AssistantRemembered from './AssistantRemembered';
|
||||
import AssistantShareDialog from './AssistantShareDialog';
|
||||
import AssistantSources from './AssistantSources';
|
||||
import AssistantToolSteps from './AssistantToolSteps';
|
||||
import AssistantWeatherStrip from './AssistantWeatherStrip';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
@@ -129,6 +131,10 @@ function Answer({ message }: { message: StoredMessage }) {
|
||||
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} />;
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { AssistantFoundPlace } from '@trek/shared';
|
||||
import { Check, ChevronDown, LoaderCircle, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { Check, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { createElement, type ReactNode, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { getCategoryIcon } from '../shared/categoryIcons';
|
||||
import { PopoverListPanel } from '../shared/dialogParts';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { usePopoverList } from '../shared/usePopoverList';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import type { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
|
||||
import { useFoundPlacePhoto } from './useFoundPlacePhoto';
|
||||
@@ -16,110 +14,14 @@ type Strip = ReturnType<typeof useAssistantPlaceStrip>;
|
||||
const ACTION =
|
||||
'inline-flex items-center gap-1 rounded-lg bg-surface-secondary text-caption font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50 disabled:hover:bg-surface-secondary';
|
||||
|
||||
/** "+ Day 3" for the day in focus, a list of the trip's days beside it; "Pick a day" when no day is in focus. */
|
||||
function SuggestButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||
/** "Add to trip": opens the planner's place form filled in with the place, its details loading beside it. */
|
||||
function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { days, focusDay, disabled, blockedKey, adding } = strip;
|
||||
const list = usePopoverList(focusDay ? days.findIndex((d) => d.id === focusDay.id) : 0, {
|
||||
estimatedHeight: Math.ceil(days.length / 7) * 44 + 16,
|
||||
});
|
||||
const { open, toggle, close, triggerRef, listId } = list;
|
||||
const height = tall ? 'h-10' : 'h-7';
|
||||
const busy = adding === place.ref;
|
||||
|
||||
const pick = (dayId: number) => {
|
||||
close();
|
||||
void strip.suggest(place, dayId);
|
||||
};
|
||||
|
||||
const buttons = (
|
||||
<span className="inline-flex min-w-0 gap-px">
|
||||
{focusDay ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void strip.suggest(place, focusDay.id)}
|
||||
aria-label={t('assistant.place.addTo', { day: focusDay.label })}
|
||||
className={`${ACTION} ${height} rounded-e-none px-2`}
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle size={12} aria-hidden="true" className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
)}
|
||||
<span className="truncate">{t('assistant.place.addToShort', { n: focusDay.n })}</span>
|
||||
</button>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
aria-label={t('assistant.place.pickDay')}
|
||||
className={`${ACTION} ${height} rounded-s-none px-1.5`}
|
||||
>
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? listId : undefined}
|
||||
className={`${ACTION} ${height} px-2`}
|
||||
>
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
<span className="truncate">{t('assistant.place.pickDay')}</span>
|
||||
<ChevronDown size={12} aria-hidden="true" className="text-content-faint" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{blockedKey ? (
|
||||
<Tooltip label={t(blockedKey)} placement="top">
|
||||
{/* A disabled button gets no pointer events; the wrapper carries the reason. */}
|
||||
<span className="inline-flex min-w-0">{buttons}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
buttons
|
||||
)}
|
||||
{/* A grid of day tiles, not a list: a month-long trip still fits in a few rows. */}
|
||||
<PopoverListPanel list={list} label={t('assistant.place.pickDay')} className="w-[15.5rem] p-2">
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((day) => {
|
||||
const current = day.id === focusDay?.id;
|
||||
return (
|
||||
<Tooltip key={day.id} label={day.label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(day.id)}
|
||||
aria-label={day.label}
|
||||
aria-pressed={current}
|
||||
className={`flex h-10 flex-col items-center justify-center rounded-lg leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
current ? 'bg-accent text-accent-text' : 'text-content hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="text-body font-semibold tabular-nums">{day.n}</span>
|
||||
{day.weekday && (
|
||||
<span className={`mt-0.5 text-caption ${current ? 'text-accent-text' : 'text-content-faint'}`}>
|
||||
{day.weekday}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverListPanel>
|
||||
</>
|
||||
<button type="button" onClick={() => strip.open(place)} className={`${ACTION} ${tall ? 'h-10' : 'h-7'} px-2`}>
|
||||
<Plus size={12} aria-hidden="true" />
|
||||
<span className="truncate">{t('assistant.place.add')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,7 +121,7 @@ function PlaceAction({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return strip.canSuggest ? <SuggestButton place={place} strip={strip} tall={tall} /> : null;
|
||||
return strip.canAdd ? <AddButton place={place} strip={strip} tall={tall} /> : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,9 +129,10 @@ function PlaceAction({
|
||||
* with the category it most likely belongs in on it (or that category's icon
|
||||
* on its colour), its name, address and rating, whether it is open now while
|
||||
* the trip runs, how far it is from the last stop of the day in focus, and one
|
||||
* way on: "+ Day 3" for a new place, "On map" for one the trip has. A click on
|
||||
* the card shows it on the map, and resting the pointer on it pins it there
|
||||
* for a moment.
|
||||
* way on: "Add to trip" for a new place, "On map" for one the trip has. A click
|
||||
* on a new place opens the place form filled in with it (one the trip has, or
|
||||
* any where nothing can be added, shows on the map), and resting the pointer on
|
||||
* it pins it there for a moment.
|
||||
*/
|
||||
export default function AssistantPlaceCard({
|
||||
place,
|
||||
@@ -259,7 +162,7 @@ export default function AssistantPlaceCard({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => strip.show(place)}
|
||||
onClick={() => (place.inTripPlaceId === null && strip.canAdd ? strip.open(place) : strip.show(place))}
|
||||
onFocus={() => strip.hover(place)}
|
||||
onBlur={() => strip.hover(null)}
|
||||
className="flex flex-col text-start outline-none focus-visible:bg-surface-hover"
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
* FE-AI-PLACES-001 to FE-AI-PLACES-010
|
||||
*
|
||||
* The strip of places a search found: what a card says, that its picture only
|
||||
* ever comes from TREK's photo proxy, "+ Day N" for the day in focus or a
|
||||
* picked one, why it waits while a change is undecided, the map's part, and
|
||||
* no "+ Day" in a journey's chat.
|
||||
* ever comes from TREK's photo proxy, "Add to trip" opening the place form on
|
||||
* the day in focus, the map's part, and no "Add to trip" in a journey's chat.
|
||||
*/
|
||||
import type { AssistantFoundPlace, AssistantMessage, AssistantPlaces } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -67,7 +66,6 @@ const answer = (parts: AssistantMessage['parts'] = []): AssistantMessage => ({
|
||||
parts,
|
||||
});
|
||||
|
||||
const addFoundPlace = vi.fn(async () => {});
|
||||
const requestCamera = vi.fn();
|
||||
const setPreviewPin = vi.fn();
|
||||
const onRevealMap = vi.fn();
|
||||
@@ -83,7 +81,6 @@ function seed({ focusDayId = 31, messages = [] as AssistantMessage[] } = {}) {
|
||||
categories: [buildCategory({ id: 5, name: 'Restaurant', icon: 'Utensils' })],
|
||||
});
|
||||
useAssistantStore.setState({
|
||||
addFoundPlace,
|
||||
requestCamera,
|
||||
setPreviewPin,
|
||||
byTrip: {
|
||||
@@ -165,34 +162,30 @@ describe('AssistantPlaceStrip', () => {
|
||||
expect(fetchPhoto).toHaveBeenCalledWith('good', 'good', 41.889, 12.47, 'Trattoria s1:0', expect.any(Function));
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-004: "+ Day 1" suggests the place for the day in focus', () => {
|
||||
it('FE-AI-PLACES-004: "Add to trip" opens the place form with the place, on the day in focus', () => {
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add to/ }));
|
||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 31);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to trip' }));
|
||||
expect(onRevealMap).toHaveBeenCalled();
|
||||
expect(useAssistantStore.getState().placeDraft).toMatchObject({ tripId: TRIP, dayId: 31, place: { ref: 's1:0' } });
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-005: another day is picked from the list, and without a focus the list is the only way', () => {
|
||||
it('FE-AI-PLACES-005: a click on a new place opens the form too, without a day when none is in focus', () => {
|
||||
seed({ focusDayId: 0 });
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Pick a day/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Nov 3/ }));
|
||||
expect(addFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 32);
|
||||
fireEvent.click(screen.getByText('Trattoria s1:0'));
|
||||
expect(useAssistantStore.getState().placeDraft).toMatchObject({ dayId: null, place: { ref: 's1:0' } });
|
||||
expect(requestCamera).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-006: waits while a change is undecided, and offline', () => {
|
||||
it('FE-AI-PLACES-006: opening the form waits for nothing: an undecided change and offline leave it on', () => {
|
||||
const waiting = answer([
|
||||
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } },
|
||||
]);
|
||||
seed({ messages: [waiting] });
|
||||
const { unmount } = renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
seed();
|
||||
setForcedOffline(true);
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.getByRole('button', { name: /^Add to/ })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Add to trip' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-007: a suggested place says so, and one the trip has goes to the map instead', () => {
|
||||
@@ -234,15 +227,14 @@ describe('AssistantPlaceStrip', () => {
|
||||
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-010: a journey chat lists the places but offers no "+ Day" while the trip is on screen', () => {
|
||||
// The journey key never matches the planner's trip, so the trip check already hides "+ Day";
|
||||
it('FE-AI-PLACES-010: a journey chat lists the places but offers no "Add to trip" while the trip is on screen', () => {
|
||||
// The journey key never matches the planner's trip, so the trip check already hides "Add to trip";
|
||||
// the scope check in the hook is defence in depth for the day trips and journeys share keys.
|
||||
seed();
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 }, answer(), {
|
||||
tripId: assistantChatKey({ kind: 'journey', journeyId: TRIP }),
|
||||
});
|
||||
expect(screen.getByText('Trattoria s1:0')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Add to/ })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Pick a day/ })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* FE-AI-REMEMBER-001 to FE-AI-REMEMBER-002
|
||||
*
|
||||
* A note the assistant kept on its own: a quiet line naming it, the trip
|
||||
* notes read again while the answer is written, and the notes a tap away.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import AssistantRemembered from './AssistantRemembered';
|
||||
import { type AssistantView, AssistantViewContext } from './assistantViewContext';
|
||||
|
||||
const SETTINGS = useSettingsStore.getState();
|
||||
|
||||
function renderLine(live: boolean) {
|
||||
const view: AssistantView = { tripId: 4, variant: 'window', density: 'compact' };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={view}>
|
||||
<AssistantRemembered note="We eat vegetarian" live={live} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState(SETTINGS, true);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AssistantRemembered', () => {
|
||||
it('FE-AI-REMEMBER-001: names the note, and reads the notes again only while the answer is written', () => {
|
||||
const loadSettings = vi.fn(async () => {});
|
||||
useSettingsStore.setState({ loadSettings });
|
||||
const { unmount } = renderLine(false);
|
||||
expect(screen.getByText('We eat vegetarian')).toBeInTheDocument();
|
||||
expect(screen.getByText('Noted for this trip:')).toBeInTheDocument();
|
||||
expect(loadSettings).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
renderLine(true);
|
||||
expect(loadSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-AI-REMEMBER-002: the pencil opens the trip notes', () => {
|
||||
useSettingsStore.setState({ loadSettings: vi.fn(async () => {}) });
|
||||
renderLine(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit trip notes' }));
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BookmarkCheck, Pencil } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantMemoryDialog from './AssistantMemoryDialog';
|
||||
import { useAssistantScope } from './assistantViewContext';
|
||||
|
||||
/**
|
||||
* A note the assistant kept in the trip notes on its own, as a quiet line in
|
||||
* its answer, with the notes a tap away to change or drop it. Kept while the
|
||||
* answer is written, the notes on this device are read again, so the dialog
|
||||
* never saves over the note with what it held before.
|
||||
*/
|
||||
export default function AssistantRemembered({ note, live }: { note: string; live: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const scope = useAssistantScope();
|
||||
const loadSettings = useSettingsStore((s) => s.loadSettings);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (live) void loadSettings();
|
||||
}, [live, loadSettings]);
|
||||
|
||||
const tripId = scope.kind === 'trip' ? scope.tripId : null;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-surface-secondary px-2.5 py-1.5 text-caption">
|
||||
<BookmarkCheck size={13} strokeWidth={2.2} aria-hidden="true" className="flex-none text-success" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-semibold text-content-secondary">{t('assistant.memory.kept')}</span>{' '}
|
||||
<bdi className="text-content-muted">{note}</bdi>
|
||||
</span>
|
||||
{tripId !== null && (
|
||||
<Tooltip label={t('assistant.memory.edit')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label={t('assistant.memory.edit')}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-md text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<Pencil size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{editing && tripId !== null && <AssistantMemoryDialog tripId={tripId} onClose={() => setEditing(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* FE-AI-SOURCES-001 to FE-AI-SOURCES-002
|
||||
*
|
||||
* The pages an answer cites from the provider's web search: a chip per page
|
||||
* with its site and title, opening in a new tab without telling the page who
|
||||
* sent the reader, and the block they form in an answer.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import AssistantSources from './AssistantSources';
|
||||
import { messageBlocks } from './assistantParts';
|
||||
|
||||
describe('AssistantSources', () => {
|
||||
it('FE-AI-SOURCES-001: names each page by its site and title, and opens it in a new tab', () => {
|
||||
render(
|
||||
<AssistantSources
|
||||
sources={[
|
||||
{ url: 'https://www.rostock.de/hafen', title: 'Stadthafen Rostock' },
|
||||
{ url: 'https://example.org/a', title: 'https://example.org/a' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole('navigation', { name: 'Sources' })).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: /rostock\.de/ });
|
||||
expect(link).toHaveAttribute('href', 'https://www.rostock.de/hafen');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link.getAttribute('rel')).toContain('noreferrer');
|
||||
expect(screen.getByText('Stadthafen Rostock')).toBeInTheDocument();
|
||||
// A title that is only the address shows the site alone.
|
||||
expect(screen.getByRole('link', { name: 'example.org' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-SOURCES-002: an answer keeps its sources as a block of their own after its text', () => {
|
||||
const blocks = messageBlocks(
|
||||
{
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
createdAt: '2026-10-23T10:00:00.000Z',
|
||||
parts: [
|
||||
{ type: 'tool-call', toolCallId: 'web-1', toolName: 'web_search', args: { query: 'x' }, result: 'searched' },
|
||||
{ type: 'text', text: 'Eat at the harbour.' },
|
||||
{ type: 'data-trek-sources', data: { sources: [{ url: 'https://a.example', title: 'A' }] } },
|
||||
],
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(blocks.map((block) => block.kind)).toEqual(['steps', 'text', 'sources']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { AssistantSources as Sources } from '@trek/shared';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
/** A page's host without "www.", as the chip names it; the whole address where it does not read as one. */
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages an answer drew on from the provider's web search, under its text:
|
||||
* a row of quiet chips with each page's site and title, opening in a new tab.
|
||||
* No page's icon is loaded, so showing the sources asks no site for anything.
|
||||
*/
|
||||
export default function AssistantSources({ sources }: { sources: Sources['sources'] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<nav aria-label={t('assistant.sources.title')} className="flex flex-col gap-1.5">
|
||||
<span className="font-geist text-caption font-bold uppercase tracking-[.08em] text-content-faint">
|
||||
{t('assistant.sources.title')}
|
||||
</span>
|
||||
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
|
||||
{sources.map((source) => (
|
||||
<li key={source.url} className="min-w-0 max-w-full">
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
title={source.title}
|
||||
className="flex min-w-0 max-w-[16rem] items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2.5 py-1 text-caption text-content-secondary no-underline transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<Globe size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
|
||||
<span className="flex-none font-medium">{hostOf(source.url)}</span>
|
||||
{source.title && source.title !== source.url && (
|
||||
<bdi className="min-w-0 truncate text-content-muted">{source.title}</bdi>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ describe('AssistantSurface', () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: 'Rain on Monday, so the market is a good pick.' },
|
||||
{ type: 'text', text: 'Rain on Monday, so Nishiki Market is a good pick.' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -29,6 +29,7 @@ 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.
|
||||
@@ -41,8 +42,9 @@ export default function AssistantSurface({
|
||||
onFocusDay,
|
||||
onRevealMap,
|
||||
bookVersion,
|
||||
onOpenTripChat,
|
||||
}),
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion]
|
||||
[tripId, variant, density, onOpen, onFocusDay, onRevealMap, bookVersion, onOpenTripChat]
|
||||
);
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
|
||||
@@ -6,8 +6,9 @@ import { Spinner } from '../shared/Spinner';
|
||||
import { type PickerTrip, useAssistantTripPicker } from './useAssistantTripPicker';
|
||||
|
||||
/**
|
||||
* Outside a trip the assistant first asks which one: the chat, its history
|
||||
* and its changes all belong to one trip. The running and the next trip lead.
|
||||
* 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.
|
||||
*/
|
||||
export default function AssistantTripPicker({ onPick }: { onPick: (trip: PickerTrip) => void }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -28,20 +28,15 @@ export default function AssistantWelcome({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { tripId, variant } = useAssistantView();
|
||||
// The Studio's chat is about a book, not a plan.
|
||||
const book = useAssistantScope().kind === 'journey';
|
||||
// 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';
|
||||
const hints = useAssistantHints(tripId, { withStays: true });
|
||||
const crowded = Math.min(hints.hints.length, MAX_VISIBLE_HINTS) > SUGGESTIONS_UNDER_HINTS;
|
||||
const offered = crowded ? suggestions.slice(0, SUGGESTIONS_UNDER_HINTS) : suggestions;
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-6">
|
||||
<EmptyState
|
||||
scene="chat"
|
||||
size={72}
|
||||
compact
|
||||
surface="var(--bg-elevated)"
|
||||
title={book ? t('journey.studio.assistant.welcome') : t('assistant.empty.title')}
|
||||
/>
|
||||
<EmptyState scene="chat" size={72} compact surface="var(--bg-elevated)" title={welcomeTitle(scope, t)} />
|
||||
{!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')}
|
||||
@@ -53,6 +48,12 @@ 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,8 +1,9 @@
|
||||
/**
|
||||
* FE-AI-WIN-001 to FE-AI-WIN-012
|
||||
* FE-AI-WIN-001 to FE-AI-WIN-014
|
||||
*
|
||||
* The desktop window around the chat: when it shows at all, the button it
|
||||
* opens from, the trip picker outside a trip, closing that keeps the chat, and
|
||||
* opens from, the home chat outside a trip with its way into one trip's chat
|
||||
* and back, 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
|
||||
@@ -36,12 +37,14 @@ 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>
|
||||
@@ -66,6 +69,11 @@ 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>
|
||||
),
|
||||
@@ -136,12 +144,36 @@ describe('AssistantWindow', () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-AI-WIN-004: outside a trip it asks which trip first, then chats there', async () => {
|
||||
it('FE-AI-WIN-004: outside a trip it opens on the home chat, whose head opens one trip chat and goes back', async () => {
|
||||
seed({ open: true, minimized: false });
|
||||
render(<AssistantWindow trip={null} />);
|
||||
expect(screen.getByRole('dialog', { name: 'Which trip?' })).toBeInTheDocument();
|
||||
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' }));
|
||||
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,6 +270,10 @@ 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,20 +1,21 @@
|
||||
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';
|
||||
|
||||
@@ -30,17 +31,18 @@ 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.
|
||||
*/
|
||||
|
||||
interface WindowTrip {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
type WindowTrip = AssistantChatTrip;
|
||||
|
||||
interface AssistantWindowProps {
|
||||
/** The trip on screen; null outside a trip, where the window asks which one first. */
|
||||
/** The trip on screen; null outside a trip, where the window opens on the home chat. */
|
||||
trip: WindowTrip | null;
|
||||
/** Opens what a card or an applied change points at, from the trip on screen. */
|
||||
onOpen?: (target: EntityTarget) => void;
|
||||
@@ -70,12 +72,16 @@ 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);
|
||||
@@ -150,6 +156,7 @@ 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)}
|
||||
@@ -162,6 +169,7 @@ function ChatWindow({
|
||||
onOpen={onOpen}
|
||||
onFocusDay={onFocusDay}
|
||||
onRevealMap={onRevealMap}
|
||||
onOpenTripChat={onOpenTripChat}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -177,19 +185,21 @@ 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 asks again.
|
||||
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (!open) setPicked(null);
|
||||
}
|
||||
|
||||
const current = trip ?? picked;
|
||||
const current = trip ?? picked ?? home.chat;
|
||||
const atHome = current.id === HOME_CHAT_KEY;
|
||||
// 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) => {
|
||||
@@ -198,6 +208,14 @@ 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) => {
|
||||
@@ -209,52 +227,27 @@ 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={onScreen ? onOpen && openOnScreen : openPicked}
|
||||
onOpen={openTarget}
|
||||
// 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}
|
||||
/>
|
||||
{minimized && trip && <AssistantLauncher variant="floating" tripId={trip.id} focusOnMount />}
|
||||
{/* 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,20 +168,6 @@
|
||||
animation: trek-assistant-busy 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* The dot beside what the assistant does right now: a soft beat, a halo going out. */
|
||||
@keyframes trek-assistant-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 6px transparent;
|
||||
}
|
||||
}
|
||||
.trek-assistant-pulse {
|
||||
animation: trek-assistant-pulse 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-assistant-pop,
|
||||
.trek-assistant-enter {
|
||||
@@ -190,8 +176,7 @@
|
||||
.trek-assistant-settle {
|
||||
transition: none;
|
||||
}
|
||||
.trek-assistant-busy,
|
||||
.trek-assistant-pulse {
|
||||
.trek-assistant-busy {
|
||||
animation: none;
|
||||
}
|
||||
.trek-assistant-shimmer {
|
||||
@@ -211,8 +196,7 @@
|
||||
[data-reduce-motion] .trek-assistant-settle {
|
||||
transition: none;
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-busy,
|
||||
[data-reduce-motion] .trek-assistant-pulse {
|
||||
[data-reduce-motion] .trek-assistant-busy {
|
||||
animation: none;
|
||||
}
|
||||
[data-reduce-motion] .trek-assistant-shimmer {
|
||||
|
||||
@@ -111,6 +111,14 @@ describe('the questions an answer offers', () => {
|
||||
|
||||
it('FE-AI-FOLLOW-007: an answer that asks the user something waits for their answer, not for "tell me more"', () => {
|
||||
expect(followUpsFor(of([{ type: 'text', text: 'Should I plan it anyway?' }]), true)).toEqual([]);
|
||||
// Not the found places' chips either, but the model's own answers to its question.
|
||||
const asked = of([
|
||||
{ type: 'data-trek-places', data: { toolCallId: 's', places: [], more: 0 } },
|
||||
{ type: 'text', text: 'Shall I plan it there?' },
|
||||
]);
|
||||
expect(followUpsFor(asked, true)).toEqual([]);
|
||||
const offered = of([...asked.parts, { type: 'data-trek-follow-ups', data: { questions: ['Yes, plan it'] } }]);
|
||||
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
|
||||
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,11 +126,12 @@ export function followUpsFor(message: AssistantMessage, canEdit: boolean): reado
|
||||
if (fromModel.length > 0) return fromModel;
|
||||
const fromCards = cardFollowUps(message, canEdit);
|
||||
if (fromCards.length > 0) return fromCards;
|
||||
// An answer that asks the user something waits for their answer: only the model's own offer fits it.
|
||||
if (endsWithQuestion(message)) return [];
|
||||
const fromChanges = changeFollowUps(message, canEdit);
|
||||
if (fromChanges !== null) return fromChanges.length > 0 ? fromChanges : GENERAL;
|
||||
const types = new Set(message.parts.map((part) => part.type));
|
||||
if (types.has('data-trek-places')) return PLACES;
|
||||
if (types.has('data-trek-weather')) return WEATHER;
|
||||
// An answer that asks the user something waits for their answer, not for "tell me more".
|
||||
return types.has('text') && !endsWithQuestion(message) ? GENERAL : [];
|
||||
return types.has('text') ? GENERAL : [];
|
||||
}
|
||||
|
||||
@@ -224,6 +224,17 @@ 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
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('messageBlocks: found places and weather', () => {
|
||||
read('s2', []),
|
||||
places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3),
|
||||
places('s3', [found('s3:0', { lat: 40 })]),
|
||||
{ type: 'text', text: 'Three good ones.' },
|
||||
{ type: 'text', text: 'Three good ones, Place s3:0 first.' },
|
||||
]),
|
||||
false
|
||||
);
|
||||
@@ -208,10 +208,66 @@ describe('messageBlocks: found places and weather', () => {
|
||||
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] });
|
||||
// The same name at the same spot, or the same Google id, shows once.
|
||||
expect(blocks[1]).toMatchObject({
|
||||
found: { toolCallId: 's3', more: 5, places: [{ ref: 's1:0' }, { ref: 's1:1' }, { ref: 's3:0' }] },
|
||||
found: { toolCallId: 's3', more: 5, places: [{ ref: 's3:0' }, { ref: 's1:0' }, { ref: 's1:1' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-008d: an answer that planned a tour shows no found places, even one whose planning failed', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
places('s1', [found('s1:0', { name: 'Treseburg' })]),
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'h1',
|
||||
toolName: 'propose_hike',
|
||||
args: {},
|
||||
result: 'Router down',
|
||||
isError: true,
|
||||
},
|
||||
{ type: 'text', text: 'Treseburg is the turning point.' },
|
||||
]),
|
||||
false
|
||||
);
|
||||
expect(blocks.map((b) => b.kind)).not.toContain('places');
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-008c: a note kept in the trip notes is a line of its own, a refused or running one a step', () => {
|
||||
const kept = (id: string, extra: Record<string, unknown>): AssistantMessagePart =>
|
||||
({
|
||||
type: 'tool-call',
|
||||
toolCallId: id,
|
||||
toolName: 'remember_for_trip',
|
||||
args: { note: ' Vegetarian ' },
|
||||
...extra,
|
||||
}) as AssistantMessagePart;
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
kept('m1', { result: { notes: 'Vegetarian' } }),
|
||||
kept('m2', { result: 'The trip notes are full.', isError: true }),
|
||||
]),
|
||||
false
|
||||
);
|
||||
expect(blocks).toMatchObject([
|
||||
{ kind: 'remembered', note: 'Vegetarian' },
|
||||
{ kind: 'steps', steps: [{ toolCallId: 'm2' }] },
|
||||
]);
|
||||
expect(messageBlocks(message([kept('m3', {})]), true)).toMatchObject([{ kind: 'steps' }]);
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-008b: an answer that names none of the places it found shows no strip', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([places('s1', [found('s1:0', { name: 'Café Ñandú' })]), { type: 'text', text: 'It rains all day.' }]),
|
||||
false
|
||||
);
|
||||
expect(blocks.map((b) => b.kind)).toEqual(['text']);
|
||||
// Named with other accents and case, it is the same place.
|
||||
const named = messageBlocks(
|
||||
message([places('s1', [found('s1:0', { name: 'Café Ñandú' })]), { type: 'text', text: 'Try cafe nandu.' }]),
|
||||
false
|
||||
);
|
||||
expect(named.map((b) => b.kind)).toEqual(['places', 'text']);
|
||||
});
|
||||
|
||||
it('FE-AI-PARTS-009: drops the places a change of the model proposes, and the strip if none is left', () => {
|
||||
const blocks = messageBlocks(
|
||||
message([
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type AssistantMessage,
|
||||
type AssistantMessagePart,
|
||||
type AssistantPlaces,
|
||||
type AssistantSources,
|
||||
type AssistantWeatherDay,
|
||||
type TrekCard,
|
||||
} from '@trek/shared';
|
||||
@@ -66,6 +67,10 @@ type MessageBlock =
|
||||
| { kind: 'change'; key: string; items: ChangeItem[] }
|
||||
| { kind: 'places'; key: string; found: AssistantPlaces }
|
||||
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] }
|
||||
// A note the model kept in the trip notes on its own, as a quiet line.
|
||||
| { kind: 'remembered'; key: string; note: string }
|
||||
// The pages the answer cites from the provider's web search, under its text.
|
||||
| { kind: 'sources'; key: string; sources: AssistantSources['sources'] }
|
||||
| CardsBlock;
|
||||
|
||||
/** How cards are laid out: the group each joins, and which ones give way to a change they served. */
|
||||
@@ -93,6 +98,70 @@ function proposed(place: AssistantFoundPlace, effects: readonly AssistantChangeE
|
||||
});
|
||||
}
|
||||
|
||||
/** A name as it is compared with the answer's words: lower case, without accents or marks. */
|
||||
function folded(text: string): string {
|
||||
return text.normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The found places the answer names, first, then the rest; null when the
|
||||
* answer is written and names none of them. A search the answer does not
|
||||
* draw on is the model's own groundwork and shows no strip.
|
||||
*/
|
||||
function namedFirst(places: readonly AssistantFoundPlace[], text: string): AssistantFoundPlace[] | null {
|
||||
if (text.trim() === '') return [...places];
|
||||
const words = folded(text);
|
||||
const named = places.filter((place) => {
|
||||
const name = folded(place.name).trim();
|
||||
return name.length >= 3 && words.includes(name);
|
||||
});
|
||||
if (named.length === 0) return null;
|
||||
return [...named, ...places.filter((place) => !named.includes(place))];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools that plan or save a tour. An answer that ran one is about the
|
||||
* tour, planned or not: the places its searches went through to find the
|
||||
* points are the model's groundwork, not suggestions, and show no strip.
|
||||
*/
|
||||
const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
|
||||
'propose_hike',
|
||||
'create_hike',
|
||||
'update_tour',
|
||||
'propose_city_walk',
|
||||
'create_city_walk',
|
||||
'plan_paddle_route',
|
||||
'plan_ski_route',
|
||||
'prepare_tour_change',
|
||||
'save_tour_change',
|
||||
]);
|
||||
|
||||
/** The tool that keeps a note in the trip notes; kept at once, without a card. */
|
||||
const REMEMBER_TOOL = 'remember_for_trip';
|
||||
|
||||
/** The note a call kept, once it is kept; null for any other call, one still running or one refused. */
|
||||
function rememberedNote(part: Extract<AssistantMessagePart, { type: 'tool-call' }>): string | null {
|
||||
if (part.toolName !== REMEMBER_TOOL || part.approval || part.result === undefined || part.isError) return null;
|
||||
const note = (part.args as { note?: unknown }).note;
|
||||
return typeof note === 'string' && note.trim() !== '' ? note.trim() : null;
|
||||
}
|
||||
|
||||
/** The strip of the answer's found places, named ones first; null when it names none or none is left. */
|
||||
function placesBlock(
|
||||
message: AssistantMessage,
|
||||
effects: readonly AssistantChangeEffect[],
|
||||
index: number
|
||||
): MessageBlock | null {
|
||||
const found = allFound(message.parts);
|
||||
const text = message.parts.map((p) => (p.type === 'text' ? p.text : '')).join(' ');
|
||||
const places = namedFirst(
|
||||
found.places.filter((place) => !proposed(place, effects)),
|
||||
text
|
||||
);
|
||||
if (!places || places.length === 0) return null;
|
||||
return { kind: 'places', key: `places-${index}`, found: { ...found, places } };
|
||||
}
|
||||
|
||||
/** Every day the answer's weather steps looked up, one entry per date (the later look wins), by date. */
|
||||
function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDay[] {
|
||||
const byDate = new Map<string, AssistantWeatherDay>();
|
||||
@@ -256,7 +325,9 @@ export function messageBlocks(
|
||||
|
||||
// An answer about a tour shows the tour, not the places its searches went through to find it.
|
||||
const changeCards = message.parts.flatMap((p) => (p.type === 'data-trek-change' && p.data.card ? [p.data.card] : []));
|
||||
const placesHidden = [...cards.values(), ...changeCards].some((card) => rules.hidesFoundPlaces?.(card) === true);
|
||||
const placesHidden =
|
||||
[...cards.values(), ...changeCards].some((card) => rules.hidesFoundPlaces?.(card) === true) ||
|
||||
message.parts.some((p) => p.type === 'tool-call' && TOUR_PLANNING_TOOLS.has(p.toolName));
|
||||
const blocks: MessageBlock[] = [];
|
||||
message.parts.forEach((part, index) => {
|
||||
if (part.type === 'text') {
|
||||
@@ -264,9 +335,12 @@ export function messageBlocks(
|
||||
return;
|
||||
}
|
||||
if (part.type === 'data-trek-places' && index === lastPlaces && !placesHidden) {
|
||||
const found = allFound(message.parts);
|
||||
const places = found.places.filter((place) => !proposed(place, effects));
|
||||
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } });
|
||||
const strip = placesBlock(message, effects, index);
|
||||
if (strip) blocks.push(strip);
|
||||
return;
|
||||
}
|
||||
if (part.type === 'data-trek-sources') {
|
||||
blocks.push({ kind: 'sources', key: `sources-${index}`, sources: part.data.sources });
|
||||
return;
|
||||
}
|
||||
if (part.type === 'data-trek-weather' && index === firstWeather) {
|
||||
@@ -286,6 +360,11 @@ export function messageBlocks(
|
||||
pushChange(blocks, item, fromStrip);
|
||||
return;
|
||||
}
|
||||
const note = rememberedNote(part);
|
||||
if (note !== null) {
|
||||
blocks.push({ kind: 'remembered', key: `remembered-${part.toolCallId}`, note });
|
||||
return;
|
||||
}
|
||||
const step: ToolStep = {
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
@@ -374,4 +453,6 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
members: { labelKey: 'assistant.change.label.members' },
|
||||
route: { labelKey: 'assistant.change.label.route' },
|
||||
difficulty: { labelKey: 'assistant.change.label.difficulty' },
|
||||
track: { labelKey: 'assistant.change.label.track' },
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
};
|
||||
|
||||
@@ -34,6 +34,17 @@ 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);
|
||||
|
||||
@@ -134,7 +134,7 @@ function BodySlot({ item, settled }: { item: ChangeItem; settled: boolean }) {
|
||||
const view = item.card ? viewOf(item.card) : null;
|
||||
if (!view?.Body || !item.card) return null;
|
||||
return (
|
||||
<div className={`pb-1.5 ps-[38px] ${settled ? 'opacity-60' : ''}`}>
|
||||
<div className={`mt-1.5 pb-1.5 ${view.wideBody ? '' : 'ps-[38px]'} ${settled ? 'opacity-60' : ''}`}>
|
||||
<Slot item={item} view={view} name="Body" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AssistantMessage, AtlasCountryCard as CountryCard } from '@trek/shared';
|
||||
import { Briefcase, MapPin, Radio } from 'lucide-react';
|
||||
import { Briefcase, Globe2, MapPin, Radio } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
@@ -8,8 +8,10 @@ import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import { CardFacts } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardMore } from '../parts/CardRows';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { CONTINENT_LABEL, continentOf, shortDay, STATUS_LOOK } from './atlasCardModel';
|
||||
import { ATLAS_PAGE } from './atlasPage';
|
||||
import { ChangeFlag, FlagTile, StatusFact } from './AtlasParts';
|
||||
@@ -112,14 +114,15 @@ export default function AtlasCountryBlock({ cards }: { cards: readonly CardEntry
|
||||
if (countries.length === 0) return null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.domain.atlas')}>
|
||||
<CardHeader icon={Globe2} tone="info" title={t('assistant.domain.atlas')} />
|
||||
{countries.map((entry, index) => (
|
||||
<div key={entry.toolCallId} className={index > 0 ? 'mt-2 border-t border-edge-faint pt-2' : ''}>
|
||||
{entry.card.kind === 'atlasCountry' && <CountrySection card={entry.card} />}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1 flex justify-end">
|
||||
<CardFoot>
|
||||
<CardOpenButton target={ATLAS_PAGE} labelKey="assistant.card.atlas.open" />
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,6 @@ export function ExpenseFacts({ card }: SlotProps) {
|
||||
<>
|
||||
<CategoryFact row={row} />
|
||||
<DateFact row={row} />
|
||||
{row.place && <PlaceFact place={row.place} />}
|
||||
{split && (
|
||||
<FactChange
|
||||
icon={Tag}
|
||||
@@ -92,13 +91,16 @@ export function ExpenseFacts({ card }: SlotProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The split under the row, and the expense's note under it: too long for the faint line beside the icons. */
|
||||
/** Under the row: the place it belongs to beside the split, and the expense's note under them. */
|
||||
export function ExpenseBody({ card }: SlotProps) {
|
||||
const row = card.rows[0];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<span className="flex flex-col items-start gap-1">
|
||||
<SplitLine row={row} />
|
||||
<span className="flex flex-col items-start gap-1.5">
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
{row.place && <PlaceFact place={row.place} />}
|
||||
<SplitLine row={row} />
|
||||
</span>
|
||||
{row.note && <NotePill note={row.note} />}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -121,8 +121,7 @@ describe('an expense in a change row', () => {
|
||||
expect(screen.getByText(money(84))).toHaveClass('line-through');
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-012: the facts name the category, day and place, and what an update changes', () => {
|
||||
const onOpen = vi.fn();
|
||||
it('FE-AI-CARD-BUD-012: the facts name the category and day, and what an update changes', () => {
|
||||
const update = expense({
|
||||
category: 'transport',
|
||||
date: '2026-06-05',
|
||||
@@ -132,7 +131,7 @@ describe('an expense in a change row', () => {
|
||||
split: 'equal',
|
||||
before: { name: null, amount: null, category: 'food', date: '2026-06-03', split: 'custom' },
|
||||
});
|
||||
inView(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />, { onOpen });
|
||||
inView(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />);
|
||||
expect(screen.getByText('Food & drink')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
||||
expect(screen.getByText('Split unevenly')).toHaveClass('line-through');
|
||||
@@ -140,6 +139,14 @@ describe('an expense in a change row', () => {
|
||||
expect(screen.getByText('1 receipt')).toBeInTheDocument();
|
||||
// The note is too long for the faint line beside the row's buttons: it sits under the row.
|
||||
expect(screen.queryByText('Window table')).toBeNull();
|
||||
// The place sits under the row, beside the split.
|
||||
expect(screen.queryByText('Lokanta')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-012c: the place sits under the row beside the split, and opens on the map', () => {
|
||||
const onOpen = vi.fn();
|
||||
const row = expense({ place: { id: 12, name: 'Lokanta' } });
|
||||
inView(<ExpenseBody card={expenses([row])} item={changeItem('a', null)} />, { onOpen });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Lokanta/ }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 12, dayId: null });
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { BUDGET_LOOK } from './budget/budgetLook';
|
||||
import type { CardLook, EntityLook, StepLook, VerbLook } from './cardTypes';
|
||||
import { COLLAB_LOOK } from './collab/collabLook';
|
||||
import { COLLECTION_LOOK } from './collection/collectionLook';
|
||||
import { HOME_LOOK } from './home/homeLook';
|
||||
import { JOURNEY_LOOK } from './journey/journeyLook';
|
||||
import { PACKING_LOOK } from './packing/packingLook';
|
||||
import { ROADTRIP_LOOK } from './roadtrip/roadtripLook';
|
||||
@@ -129,6 +130,8 @@ export const TOOL_LABELS: Readonly<Partial<Record<string, { labelKey: string }>>
|
||||
/** How each reading tool is named while it runs; a tool not listed reads "Working on it". */
|
||||
export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
|
||||
get_trip_summary: { stepKey: 'assistant.step.reading' },
|
||||
// The provider's own web search, told as a step of its own (ASSISTANT_WEB_SEARCH_STEP).
|
||||
web_search: { stepKey: 'assistant.step.webSearch', live: { liveKey: 'assistant.step.webSearchFor', param: 'query' } },
|
||||
list_places: { stepKey: 'assistant.step.reading' },
|
||||
search_place: {
|
||||
stepKey: 'assistant.step.searching',
|
||||
@@ -168,6 +171,7 @@ export const STEP_LOOKS: Readonly<Partial<Record<string, StepLook>>> = {
|
||||
...PACKING_LOOK.steps,
|
||||
...BOOK_LOOK.steps,
|
||||
...BOOK_DESIGN_LOOK.steps,
|
||||
...HOME_LOOK.steps,
|
||||
};
|
||||
|
||||
/** Shown on the map by its day: a stop or a note lives on one. */
|
||||
@@ -232,6 +236,7 @@ export const CARD_LOOKS: { readonly [K in TrekCardKind]: CardLook<K> } = {
|
||||
...PACKING_LOOK.cards,
|
||||
...BOOK_LOOK.cards,
|
||||
...BOOK_DESIGN_LOOK.cards,
|
||||
...HOME_LOOK.cards,
|
||||
};
|
||||
|
||||
/** The look of the card in hand, typed for any kind. */
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface CardView<K extends TrekCardKind> {
|
||||
Facts?: ComponentType<CardSlotProps<K>>;
|
||||
Body?: ComponentType<CardSlotProps<K>>;
|
||||
ownsSubject?: boolean;
|
||||
/** The body runs the whole width of the change card instead of under the row's text (a tour's drawing). */
|
||||
wideBody?: boolean;
|
||||
Group?: ComponentType<CardRunProps & { place: 'head' | 'foot' }>;
|
||||
Run?: ComponentType<CardRunProps>;
|
||||
runnable?(card: TrekCardOf<K>): boolean;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BUDGET_VIEWS } from './budget/budgetViews';
|
||||
import type { CardView } from './cardTypes';
|
||||
import { COLLAB_VIEWS } from './collab/collabViews';
|
||||
import { COLLECTION_VIEWS } from './collection/collectionViews';
|
||||
import { HOME_VIEWS } from './home/homeViews';
|
||||
import { JOURNEY_VIEWS } from './journey/journeyViews';
|
||||
import { PACKING_VIEWS } from './packing/packingViews';
|
||||
import { ROADTRIP_VIEWS } from './roadtrip/roadtripViews';
|
||||
@@ -33,6 +34,7 @@ export const CARD_VIEWS: { readonly [K in TrekCardKind]: CardView<K> } = {
|
||||
...PACKING_VIEWS,
|
||||
...BOOK_VIEWS,
|
||||
...BOOK_DESIGN_VIEWS,
|
||||
...HOME_VIEWS,
|
||||
};
|
||||
|
||||
/** The view of the card in hand, typed for any kind. */
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* FE-AI-TRIPCHAT-001 to FE-AI-TRIPCHAT-004
|
||||
*
|
||||
* The home chat's card that offers a trip's own chat: the trip under its head
|
||||
* with its dates, where it stands and its places, the question waiting there,
|
||||
* and the button that goes on in that chat, shown only where a chat can be
|
||||
* switched to.
|
||||
*/
|
||||
import type { TripChatCard } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import { TripChatBlock } from './TripChatBlock';
|
||||
|
||||
const CARD: TripChatCard = {
|
||||
kind: 'tripChat',
|
||||
trip: { id: 5, title: 'Kyoto', startDate: '2026-11-02', endDate: '2026-11-09', phase: 'next', placeCount: 12 },
|
||||
question: 'What is still missing?',
|
||||
};
|
||||
|
||||
function entry(card: TripChatCard, toolCallId = 'c1'): CardEntry {
|
||||
return { toolCallId, card };
|
||||
}
|
||||
|
||||
function show(cards: CardEntry[], view: Partial<AssistantView> = {}) {
|
||||
const value: AssistantView = { tripId: 0, variant: 'window', ...view };
|
||||
return render(
|
||||
<AssistantViewContext.Provider value={value}>
|
||||
<TripChatBlock cards={cards} />
|
||||
</AssistantViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('TripChatBlock', () => {
|
||||
it('FE-AI-TRIPCHAT-001: shows the trip, where it stands, its places and the question', () => {
|
||||
show([entry(CARD)]);
|
||||
expect(screen.getByRole('region', { name: 'Continue in a trip chat' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Kyoto')).toBeInTheDocument();
|
||||
expect(screen.getByText('Trip chat')).toBeInTheDocument();
|
||||
expect(screen.getByText(/ to /)).toBeInTheDocument();
|
||||
expect(screen.getByText('Next')).toBeInTheDocument();
|
||||
expect(screen.getByText('12 places')).toBeInTheDocument();
|
||||
expect(screen.getByText('What is still missing?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FE-AI-TRIPCHAT-002: the button goes on in the trip's chat with the question", () => {
|
||||
const onOpenTripChat = vi.fn();
|
||||
show([entry(CARD)], { onOpenTripChat });
|
||||
fireEvent.click(screen.getByRole('button', { name: "Continue in this trip's chat" }));
|
||||
expect(onOpenTripChat).toHaveBeenCalledWith({ id: 5, title: 'Kyoto' }, 'What is still missing?');
|
||||
});
|
||||
|
||||
it('FE-AI-TRIPCHAT-003: no button where no chat can be switched to, and a trip without dates says so', () => {
|
||||
const bare: TripChatCard = {
|
||||
kind: 'tripChat',
|
||||
trip: { id: 6, title: 'Someday', startDate: null, endDate: null, phase: 'undated', placeCount: null },
|
||||
question: null,
|
||||
};
|
||||
show([entry(bare)]);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No dates yet')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Next')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-AI-TRIPCHAT-004: two trips sit in one card, each with its own button; nothing to show draws nothing', () => {
|
||||
const onOpenTripChat = vi.fn();
|
||||
const other: TripChatCard = {
|
||||
...CARD,
|
||||
trip: { ...CARD.trip, id: 7, title: 'Oslo', phase: 'now', startDate: '2026-10-20', endDate: '2026-10-20' },
|
||||
question: null,
|
||||
};
|
||||
show([entry(CARD), entry(other, 'c2')], { onOpenTripChat, variant: 'fullscreen' });
|
||||
const buttons = screen.getAllByRole('button', { name: "Continue in this trip's chat" });
|
||||
expect(buttons).toHaveLength(2);
|
||||
fireEvent.click(buttons[1]);
|
||||
expect(onOpenTripChat).toHaveBeenCalledWith({ id: 7, title: 'Oslo' }, null);
|
||||
expect(screen.getByText('Under way')).toBeInTheDocument();
|
||||
|
||||
const { container } = render(<TripChatBlock cards={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { TripChatCard, TripChatPhase } from '@trek/shared';
|
||||
import { ArrowRight, Luggage, MessageSquareText } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
|
||||
/**
|
||||
* The home chat's way into one trip's own chat: the trip under its head (its
|
||||
* dates, where it stands, how many places it holds), the question that waits
|
||||
* there ready to send, and the button that goes on in that chat. The button
|
||||
* shows only where a chat can be switched to; the window switches in place,
|
||||
* the phone opens the trip with its chat.
|
||||
*/
|
||||
|
||||
/** Where the trip stands, as a pill; a trip without dates says so on its line instead. */
|
||||
const PHASE: Readonly<Record<Exclude<TripChatPhase, 'undated'>, { labelKey: string; tone: PillTone }>> = {
|
||||
now: { labelKey: 'assistant.card.tripChat.phase.now', tone: 'success' },
|
||||
next: { labelKey: 'assistant.card.tripChat.phase.next', tone: 'accent' },
|
||||
upcoming: { labelKey: 'assistant.card.tripChat.phase.upcoming', tone: 'info' },
|
||||
past: { labelKey: 'assistant.card.tripChat.phase.past', tone: 'neutral' },
|
||||
};
|
||||
|
||||
function TripChatSection({ card, first }: { card: TripChatCard; first: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { onOpenTripChat, variant } = useAssistantView();
|
||||
const format = useCardFormat();
|
||||
const { trip, question } = card;
|
||||
const start = trip.startDate ?? trip.endDate;
|
||||
const end = trip.endDate ?? trip.startDate;
|
||||
let dates = t('assistant.card.tripChat.noDates');
|
||||
if (start && end) {
|
||||
dates =
|
||||
start === end
|
||||
? format.date(start)
|
||||
: t('assistant.card.tripChat.dates', { start: format.date(start), end: format.date(end) });
|
||||
}
|
||||
const phase = trip.phase === 'undated' ? null : PHASE[trip.phase];
|
||||
return (
|
||||
<div className={first ? '' : 'mt-3 border-t border-edge-faint pt-3'}>
|
||||
{first ? (
|
||||
<CardHeader
|
||||
icon={Luggage}
|
||||
tone="info"
|
||||
eyebrow={t('assistant.card.tripChat.eyebrow')}
|
||||
title={trip.title}
|
||||
meta={dates}
|
||||
>
|
||||
<TripFacts phase={phase} placeCount={trip.placeCount} />
|
||||
</CardHeader>
|
||||
) : (
|
||||
<div className="mb-2.5">
|
||||
<bdi className="block truncate text-body font-bold text-content">{trip.title}</bdi>
|
||||
<div className="mt-0.5 text-caption text-content-muted">{dates}</div>
|
||||
<TripFacts phase={phase} placeCount={trip.placeCount} />
|
||||
</div>
|
||||
)}
|
||||
{question && (
|
||||
<p className="m-0 flex items-start gap-2 rounded-lg bg-surface-secondary px-3 py-2 text-body text-content">
|
||||
<MessageSquareText size={14} aria-hidden="true" className="mt-0.5 flex-none text-content-muted" />
|
||||
<span className="sr-only">{t('assistant.card.tripChat.question')}</span>
|
||||
<bdi className="min-w-0 flex-1 break-words">{question}</bdi>
|
||||
</p>
|
||||
)}
|
||||
{onOpenTripChat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTripChat({ id: trip.id, title: trip.title }, question)}
|
||||
className={`mt-2.5 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 text-body font-semibold text-accent-text transition-colors hover:bg-accent-hover ${
|
||||
variant === 'fullscreen' ? 'h-11' : 'h-9'
|
||||
}`}
|
||||
>
|
||||
{t('assistant.card.tripChat.open')}
|
||||
<ArrowRight size={15} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The pills under the trip's name: where it stands and how many places it holds. */
|
||||
function TripFacts({
|
||||
phase,
|
||||
placeCount,
|
||||
}: {
|
||||
phase: { labelKey: string; tone: PillTone } | null;
|
||||
placeCount: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!phase && !placeCount) return null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{phase && <SoftPill tone={phase.tone}>{t(phase.labelKey)}</SoftPill>}
|
||||
{!!placeCount && <WhiteBadge>{t('collections.placeCount', { count: placeCount })}</WhiteBadge>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TripChatBlock({ cards }: { cards: readonly CardEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
const trips = cards.flatMap((entry) => (entry.card.kind === 'tripChat' ? [entry.card] : []));
|
||||
if (trips.length === 0) return null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.tripChat.label')}>
|
||||
{trips.map((card, index) => (
|
||||
<TripChatSection key={`${card.trip.id}-${index}`} card={card} first={index === 0} />
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { PackLook } from '../cardTypes';
|
||||
|
||||
/**
|
||||
* What the home chat (the assistant outside any one trip) brings to the
|
||||
* chat's pure look: the words its reads run under and the look of the card
|
||||
* that offers a trip's own chat. It has no verbs or entities of its own; the
|
||||
* changes it proposes are the atlas', the vacation planner's and the
|
||||
* collections'. A look stays pure, literals only.
|
||||
*/
|
||||
export const HOME_LOOK = {
|
||||
verbs: {},
|
||||
tools: {},
|
||||
steps: {
|
||||
list_trips: { stepKey: 'assistant.step.readingTrips' },
|
||||
open_trip_chat: { stepKey: 'assistant.step.openTripChat' },
|
||||
},
|
||||
entities: {},
|
||||
cards: {
|
||||
tripChat: {},
|
||||
},
|
||||
} satisfies PackLook<'tripChat'>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { CardView } from '../cardTypes';
|
||||
import { TripChatBlock } from './TripChatBlock';
|
||||
|
||||
/**
|
||||
* How the home chat's kind draws: the trip it offers to go on with, beside
|
||||
* the answer. This file only maps the kind to its view; the component lives
|
||||
* in a sibling file, since a file that exports anything besides components
|
||||
* breaks fast refresh.
|
||||
*/
|
||||
export const HOME_VIEWS = {
|
||||
tripChat: { Block: TripChatBlock },
|
||||
} satisfies { [K in 'tripChat']: CardView<K> };
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { AssistantMessage, JourneyEntryLook } from '@trek/shared';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { ArrowUpRight, BookOpen } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { DAY_COLORS } from '../../../Journey/dayColors';
|
||||
import { cardTitle } from '../../../Journey/journeyCard';
|
||||
import JourneyEntryCover from '../../../Journey/JourneyEntryCover';
|
||||
import { LinkedJournalIcon } from '../../../Trips/LinkedJournalIcon';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import CardStrip from '../parts/CardStrip';
|
||||
import { type JournalEntries, coverEntryOf, mergeJournalCards, titleSourceOf } from './journeyCardModel';
|
||||
import { EntryMarks, EntryStory, EntryThumb, EntryWhen } from './JourneyEntryParts';
|
||||
@@ -33,9 +34,6 @@ const TITLE_BUTTON =
|
||||
'block w-full rounded-md text-start outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent';
|
||||
/** The phone's taller target, the title still at the top of the row. */
|
||||
const TITLE_BUTTON_TALL = `${TITLE_BUTTON} min-h-10`;
|
||||
const FOOTER = 'mt-1 flex items-center gap-2 border-t border-edge-faint pt-2 text-caption';
|
||||
const FOOTER_TITLE = 'min-w-0 flex-1 truncate text-content-muted';
|
||||
const FAINT = 'text-content-faint';
|
||||
const OPEN = 'inline-flex flex-none items-center gap-1 font-medium text-accent hover:underline';
|
||||
const OPEN_TALL = 'inline-flex h-10 flex-none items-center gap-1 font-medium text-accent hover:underline';
|
||||
/** The size and corners of the journal's own tile, so the count stands in the row like one more entry. */
|
||||
@@ -47,29 +45,40 @@ const TILE = 'flex-none snap-start';
|
||||
|
||||
type Open = ((entryId: number | null) => void) | null;
|
||||
|
||||
/** The journal's name and the way into it, under the card. */
|
||||
function JournalFooter({ journal, open }: { journal: JournalEntries; open: Open }) {
|
||||
/**
|
||||
* The way into the journal under its entries: in the foot bar of the card, or
|
||||
* under the strip of tiles with how many entries it holds, which the strip
|
||||
* has no band to say.
|
||||
*/
|
||||
function JournalFooter({ open, count }: { open: Open; count?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const title = journal.journalTitle ?? t('assistant.card.journey.label');
|
||||
const count = journal.entries.length + journal.more;
|
||||
const button = open && (
|
||||
<button type="button" onClick={() => open(null)} className={variant === 'fullscreen' ? OPEN_TALL : OPEN}>
|
||||
{t('assistant.card.journey.open')}
|
||||
<ArrowUpRight size={12} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
);
|
||||
if (count === undefined) return button ? <CardFoot>{button}</CardFoot> : null;
|
||||
return (
|
||||
<div className={FOOTER}>
|
||||
<LinkedJournalIcon size={12} className={FAINT} />
|
||||
<span title={title} className={FOOTER_TITLE}>
|
||||
<bdi>{title}</bdi>
|
||||
{count > 2 && <span className="ms-1.5 tabular-nums">{t('assistant.card.journey.entries', { count })}</span>}
|
||||
<div className="mt-1 flex items-center gap-2 text-caption">
|
||||
<span className="min-w-0 flex-1 truncate tabular-nums text-content-muted">
|
||||
{t('assistant.card.journey.entries', { count })}
|
||||
</span>
|
||||
{open && (
|
||||
<button type="button" onClick={() => open(null)} className={variant === 'fullscreen' ? OPEN_TALL : OPEN}>
|
||||
{t('assistant.card.journey.open')}
|
||||
<ArrowUpRight size={12} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The band of the entries: the journal's name, and how many entries it holds once there are more than two. */
|
||||
function JournalHead({ journal }: { journal: JournalEntries }) {
|
||||
const { t } = useTranslation();
|
||||
const title = journal.journalTitle ?? t('assistant.card.journey.label');
|
||||
const count = journal.entries.length + journal.more;
|
||||
const meta = count > 2 ? t('assistant.card.journey.entries', { count }) : undefined;
|
||||
return <CardHeader icon={BookOpen} tone="info" title={title} meta={meta} />;
|
||||
}
|
||||
|
||||
/** One entry as a row: its photo, its title (the way to it), its facts and the start of its text. */
|
||||
function EntryRow({
|
||||
look,
|
||||
@@ -116,10 +125,11 @@ function JournalRows({ journal, fallbackRef }: { journal: JournalEntries; fallba
|
||||
const hover = useEntryHover(journal.journeyId, fallbackRef);
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.journey.label')}>
|
||||
<JournalHead journal={journal} />
|
||||
{journal.entries.map((look, index) => (
|
||||
<EntryRow key={look.entryId ?? `new-${index}`} look={look} divided={index > 0} open={open} hover={hover} />
|
||||
))}
|
||||
<JournalFooter journal={journal} open={open} />
|
||||
<JournalFooter open={open} />
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -152,7 +162,7 @@ function JournalStrip({ journal, fallbackRef }: { journal: JournalEntries; fallb
|
||||
</li>
|
||||
)}
|
||||
</CardStrip>
|
||||
<JournalFooter journal={journal} open={open} />
|
||||
<JournalFooter open={open} count={journal.entries.length + journal.more} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { PackingListCard as PackingCard, PackingCardGroup } from '@trek/shared';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Backpack, Check } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { PackedBar, PackTile } from '../../../Tours/packing/packingLinkParts';
|
||||
import { PackedBar } from '../../../Tours/packing/packingLinkParts';
|
||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import { isRunnable, mergePackingCards, packingColorOf, rowCount } from './checklistModel';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { isRunnable, mergePackingCards, rowCount } from './checklistModel';
|
||||
import { ChecklistFoot, ChecklistRun, type RunEntry, type RunProps } from './checklistParts';
|
||||
import { PackingGroup, PackingRowAside, PackingRowFacts } from './packingParts';
|
||||
|
||||
@@ -62,7 +64,7 @@ function useTitle(card: PackingCard): string {
|
||||
: t('assistant.card.packing.title');
|
||||
}
|
||||
|
||||
/** The head of an answer: the tile with the count, the title, how much is packed, and "All packed" once it is. */
|
||||
/** The head band of an answer: the backpack, the title, how much is packed with its bar, and "All packed" once it is. */
|
||||
function PackingHead({ card }: { card: PackingCard }) {
|
||||
const { t } = useTranslation();
|
||||
const title = useTitle(card);
|
||||
@@ -73,50 +75,46 @@ function PackingHead({ card }: { card: PackingCard }) {
|
||||
if (source.type === 'tour') caption = t('assistant.card.packing.forTour');
|
||||
else if (source.type === 'template') caption = t('assistant.card.packing.templateAdds', { count: source.itemCount });
|
||||
else if (card.progress && total > 0) caption = t('tours.packing.packed', { packed, count: total });
|
||||
const first = card.groups[0]?.key;
|
||||
const color =
|
||||
source.type === 'tour' && source.category !== null && first?.by === 'category'
|
||||
? packingColorOf(first.slot, source.category)
|
||||
: null;
|
||||
const done = card.progress !== null && total > 0 && packed === total;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<PackTile count={source.type === 'template' ? source.itemCount : total} color={color} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p title={title} className="m-0 line-clamp-2 text-body font-semibold leading-tight text-content">
|
||||
{title}
|
||||
</p>
|
||||
{caption && (
|
||||
<p title={caption} className="m-0 truncate text-caption text-content-faint">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{card.progress && total > 0 && packed === total && (
|
||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||
{t('tours.packing.allPacked')}
|
||||
</SoftPill>
|
||||
<CardHeader
|
||||
icon={Backpack}
|
||||
tone={done ? 'success' : 'info'}
|
||||
title={title}
|
||||
count={source.type === 'template' ? source.itemCount : total}
|
||||
meta={caption ?? undefined}
|
||||
actions={
|
||||
done ? (
|
||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||
{t('tours.packing.allPacked')}
|
||||
</SoftPill>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{card.progress && card.progress.total > 0 && (
|
||||
<div className="mt-2.5">
|
||||
<PackedBar packed={card.progress.packed} total={card.progress.total} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
/** One answer card. */
|
||||
function PackingAnswer({ card }: { card: PackingCard }) {
|
||||
const title = useTitle(card);
|
||||
const progress = card.progress;
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<PackingHead card={card} />
|
||||
{progress && progress.total > 0 && <PackedBar packed={progress.packed} total={progress.total} />}
|
||||
<PackingGroups card={card} inChange={false} />
|
||||
<PackingHead card={card} />
|
||||
<PackingGroups card={card} inChange={false} />
|
||||
<CardFoot>
|
||||
<ChecklistFoot
|
||||
more={card.moreGroups}
|
||||
moreKey={MORE_GROUPS[card.groupBy].labelKey}
|
||||
target={PACKING_TARGET}
|
||||
openKey="tours.packing.open"
|
||||
/>
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts'
|
||||
import { localToday } from '../../../Planner/today';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { PRIO_CONFIG } from '../../../Todo/todoListModel';
|
||||
import { PackTile } from '../../../Tours/packing/packingLinkParts';
|
||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { PersonFace } from '../parts/CardPeople';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { isRunnable, mergeTodoCards, todoColorOf } from './checklistModel';
|
||||
import {
|
||||
@@ -204,27 +205,30 @@ function TodoAnswer({ card }: { card: TodoCard }) {
|
||||
const title = t('assistant.card.todo.title');
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<PackTile count={progress?.open ?? 0} color={null} icon={ListTodo} />
|
||||
<p className="m-0 text-body font-semibold leading-tight text-content">{title}</p>
|
||||
{progress && <SoftPill>{t('assistant.card.todo.openCount', { count: progress.open })}</SoftPill>}
|
||||
{progress && progress.overdue > 0 && (
|
||||
<CardHeader
|
||||
icon={ListTodo}
|
||||
tone={progress && progress.overdue > 0 ? 'danger' : 'info'}
|
||||
title={title}
|
||||
meta={progress ? t('assistant.card.todo.openCount', { count: progress.open }) : undefined}
|
||||
actions={
|
||||
progress && progress.overdue > 0 ? (
|
||||
<SoftPill tone="danger">{t('assistant.card.todo.overdueCount', { count: progress.overdue })}</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{card.groups.map((group) => (
|
||||
<TodoGroup key={group.category ?? ''} group={group} inChange={false} />
|
||||
))}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{card.groups.map((group) => (
|
||||
<TodoGroup key={group.category ?? ''} group={group} inChange={false} />
|
||||
))}
|
||||
</div>
|
||||
<CardFoot>
|
||||
<ChecklistFoot
|
||||
more={card.moreGroups}
|
||||
moreKey="assistant.card.todo.moreLists"
|
||||
target={TODO_TARGET}
|
||||
openKey="assistant.card.todo.open"
|
||||
/>
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ChecklistRow({
|
||||
>
|
||||
{name}
|
||||
</bdi>
|
||||
{aside && <span className="flex flex-none flex-wrap items-center justify-end gap-1">{aside}</span>}
|
||||
{aside && <span className="flex flex-none flex-wrap items-center justify-end gap-x-2 gap-y-1">{aside}</span>}
|
||||
</div>
|
||||
{under}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { formatWeight } from '../../../Packing/packingListModel';
|
||||
import { bagFillPct } from '../../../Packing/packingListPanel.helpers';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { CountBadge } from '../../../Tours/packing/packingLinkParts';
|
||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||
import { PeopleStack, PersonFace } from '../parts/CardPeople';
|
||||
import { packingColorOf } from './checklistModel';
|
||||
@@ -56,15 +55,31 @@ function BagSwatch({ bag, size = 'row' }: { bag: PackingCardBag | undefined; siz
|
||||
* What a row says at its end: how many, how many packed, its weight, its tier
|
||||
* and its bag. A bag the card does not carry draws nothing rather than "Unassigned".
|
||||
*/
|
||||
/** A row's quantity and weight: quiet figures at its end, no frame each. */
|
||||
const QUIET = 'text-caption tabular-nums text-content-muted';
|
||||
|
||||
/** How many rows a group holds, quiet beside its name. */
|
||||
function GroupCount({ count }: { count: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label={t('tours.packing.items', { count })}
|
||||
className="flex-none text-caption tabular-nums text-content-faint"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PackingRowAside({ row, bag }: { row: PackingCardItem; bag: PackingCardBag | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const personal = t('assistant.card.packing.tierPersonal');
|
||||
const partial = !row.checked && row.packed > 0 && row.packed < row.quantity;
|
||||
return (
|
||||
<>
|
||||
{row.quantity > 1 && <WhiteBadge>×{row.quantity}</WhiteBadge>}
|
||||
{partial && <WhiteBadge>{`${row.packed}/${row.quantity}`}</WhiteBadge>}
|
||||
{row.weightGrams !== null && <WhiteBadge>{formatWeight(row.weightGrams)}</WhiteBadge>}
|
||||
{row.quantity > 1 && <span className={QUIET}>×{row.quantity}</span>}
|
||||
{partial && <span className={QUIET}>{`${row.packed}/${row.quantity}`}</span>}
|
||||
{row.weightGrams !== null && <span className={QUIET}>{formatWeight(row.weightGrams)}</span>}
|
||||
{row.tier === 'personal' && (
|
||||
<Tooltip label={personal}>
|
||||
<span role="img" aria-label={personal} className="flex text-content-faint">
|
||||
@@ -216,7 +231,7 @@ function BagHead({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!bag) {
|
||||
const badge = inChange ? undefined : <CountBadge count={group.count} />;
|
||||
const badge = inChange ? undefined : <GroupCount count={group.count} />;
|
||||
const lead = <BagSwatch bag={undefined} size="head" />;
|
||||
return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />;
|
||||
}
|
||||
@@ -283,7 +298,7 @@ export function PackingGroup({
|
||||
<ChecklistHead
|
||||
color={packingColorOf(key.slot, name)}
|
||||
name={name}
|
||||
badge={inChange ? undefined : <CountBadge count={group.count} />}
|
||||
badge={inChange ? undefined : <GroupCount count={group.count} />}
|
||||
people={group.assignees}
|
||||
removed={group.assigneesRemoved}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* The frame every answer card sits in, the same as the change card's: a
|
||||
* rounded card on the chat. A waiting change shows its frame dashed, a
|
||||
* rounded card on the chat whose head band (CardHeader) runs to its edges. A waiting change shows its frame dashed, a
|
||||
* removed one faded, a settled one (discarded, expired, undone) fainter
|
||||
* still. The chat card (a message in the trip chat) sits on the quieter
|
||||
* secondary surface.
|
||||
@@ -32,7 +32,7 @@ export default function CardFrame({
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className={`trek-assistant-enter rounded-xl border px-3 pb-2 pt-2.5 shadow-card ${STATE[state]} ${
|
||||
className={`trek-assistant-enter overflow-hidden rounded-[16px] border px-3.5 pb-3 pt-3 shadow-card ${STATE[state]} ${
|
||||
surface === 'secondary' ? 'bg-surface-secondary' : 'bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,43 +1,94 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { WhiteBadge } from '../../../Planner/planParts';
|
||||
import { fs, NEUTRAL_TINT } from '../../../shared/DialogShell';
|
||||
import type { Tone } from './assistantTones';
|
||||
import KindTile from './KindTile';
|
||||
|
||||
/**
|
||||
* The head of an answer card: its tile, what it is about (wrapping to two
|
||||
* lines at most, the whole title in its tooltip), how many it holds, one
|
||||
* quiet line of context under it, and its actions at the end.
|
||||
* The head band of an answer card, as the trip's dialogs and booking cards
|
||||
* wear it: a tinted band across the card's top, a raised white tile with the
|
||||
* icon of what the card is about in its tone, an eyebrow naming the kind,
|
||||
* the title, one quiet line under it, a count, and the card's actions at the
|
||||
* end. It reaches the card's edges, so a card that opens with it needs no
|
||||
* other head.
|
||||
*/
|
||||
|
||||
/** The icon's colour on the white tile, per tone. */
|
||||
const GLYPH: Record<Tone, string> = {
|
||||
success: 'text-success',
|
||||
info: 'text-info',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-danger',
|
||||
};
|
||||
|
||||
export default function CardHeader({
|
||||
icon,
|
||||
icon: Icon,
|
||||
tone,
|
||||
title,
|
||||
eyebrow,
|
||||
count,
|
||||
meta,
|
||||
actions,
|
||||
tile,
|
||||
tint = NEUTRAL_TINT,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
tone: Tone;
|
||||
title: string;
|
||||
/** The kind above the title ("PACKING LIST"); none when the title says it. */
|
||||
eyebrow?: string;
|
||||
count?: number;
|
||||
meta?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
/** In place of the icon tile: a drawing of what the card shows (a route's sketch). */
|
||||
tile?: ReactNode;
|
||||
tint?: string;
|
||||
/** Under the title row, on the band: a progress bar, a row of pills. */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<KindTile icon={icon} tone={tone} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<bdi title={title} className="line-clamp-2 text-body font-semibold leading-tight text-content">
|
||||
{title}
|
||||
</bdi>
|
||||
{count !== undefined && <WhiteBadge>{count}</WhiteBadge>}
|
||||
<div className="-mx-3.5 -mt-3 mb-3 px-3.5 pb-2.5 pt-2.5" style={{ background: tint }}>
|
||||
<div className={`flex gap-2.5 ${meta ? 'items-start' : 'items-center'}`}>
|
||||
{tile ?? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card shadow-sm ${GLYPH[tone]}`}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{eyebrow && (
|
||||
<div
|
||||
className="truncate font-geist font-bold uppercase tracking-[.08em] text-content-muted"
|
||||
style={fs(9.5)}
|
||||
>
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<bdi
|
||||
title={title}
|
||||
className="line-clamp-2 min-w-0 font-bold leading-tight tracking-[-0.01em] text-content"
|
||||
style={fs(13.5, 'body')}
|
||||
>
|
||||
{title}
|
||||
</bdi>
|
||||
{count !== undefined && (
|
||||
<span
|
||||
className="inline-flex h-[18px] flex-none items-center rounded-full bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary shadow-sm"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{meta && <div className="mt-0.5 font-geist text-caption text-content-muted">{meta}</div>}
|
||||
</div>
|
||||
{meta && <div className="mt-0.5 text-caption text-content-muted">{meta}</div>}
|
||||
{actions && <div className="flex flex-none items-center gap-1">{actions}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-none items-center gap-1">{actions}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,11 @@ export function CardRow({
|
||||
<span className="block text-body font-semibold leading-tight text-content">{title}</span>
|
||||
{facts}
|
||||
</span>
|
||||
{aside && <span className="flex-none text-caption tabular-nums text-content-secondary">{aside}</span>}
|
||||
{aside && (
|
||||
<span className="flex flex-none items-center gap-2 text-caption tabular-nums text-content-secondary">
|
||||
{aside}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const hover = onHover
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Eyebrow, Field } from '../../../Planner/bookings/bookingParts';
|
||||
|
||||
/**
|
||||
* The body of an answer card in the trip dialogs' look: sections under an
|
||||
* eyebrow of their own, figures as framed fields with their label over them
|
||||
* (the field every booking card is made of), and a foot bar for the card's
|
||||
* ways onward, set off by a line across the card.
|
||||
*/
|
||||
|
||||
/** One part of a card under its eyebrow, with room for something on the right of the label. */
|
||||
export function CardSection({ label, aside, children }: { label: string; aside?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="mt-3 first:mt-0">
|
||||
<div className="mb-1.5 flex min-h-4 items-center gap-2">
|
||||
<Eyebrow>{label}</Eyebrow>
|
||||
{aside && <span className="ms-auto flex items-center gap-1">{aside}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A card's figures, side by side in framed fields: as many columns as figures, at most four. */
|
||||
export function CardFields({ fields }: { fields: readonly { key: string; label: string; value: ReactNode }[] }) {
|
||||
if (fields.length === 0) return null;
|
||||
const columns = ['grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4'][Math.min(fields.length, 4) - 1];
|
||||
return (
|
||||
<div className={`grid gap-1.5 ${columns}`}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="min-w-0" data-field={field.key}>
|
||||
<Field label={field.label} tabular>
|
||||
{field.value}
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The foot of a card: its actions at the end, under a line that runs to the card's edges. */
|
||||
export function CardFoot({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="-mx-3.5 -mb-3 mt-3 flex flex-wrap items-center justify-end gap-1.5 border-t border-edge-faint bg-surface-secondary px-3.5 py-2.5">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Clock, MoveHorizontal, TrendingUp } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { DistanceUnit } from '../../../../types';
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
import type { LegVariant } from '../../../Tours/planner/legVariants';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
|
||||
/**
|
||||
* A way's length, climb and time as one quiet line of glyphs and values,
|
||||
* without a frame around each: what a choice of ways and a change row show
|
||||
* where framed fields would crowd the row.
|
||||
*/
|
||||
|
||||
/** One figure of a way: its glyph and its value, quiet, in a line of them. */
|
||||
function Figure({ name, icon, children }: { name: string; icon: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap" data-figure={name}>
|
||||
{icon}
|
||||
<span className="tabular-nums">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const GLYPH = { size: 11, strokeWidth: 2.2, 'aria-hidden': true, className: 'flex-none text-content-faint' } as const;
|
||||
|
||||
/** A way's length, climb and time in one quiet line. */
|
||||
export default function WayFigures({ way, unit }: { way: LegVariant; unit: DistanceUnit }) {
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-caption text-content-secondary">
|
||||
<Figure name="distance" icon={<MoveHorizontal {...GLYPH} />}>
|
||||
{formatDistance(way.distanceM / 1000, unit)}
|
||||
</Figure>
|
||||
{way.gainM !== null && (
|
||||
<Figure name="ascent" icon={<TrendingUp {...GLYPH} />}>
|
||||
{formatElevation(way.gainM, unit)}
|
||||
</Figure>
|
||||
)}
|
||||
{way.minutes !== null && (
|
||||
<Figure name="time" icon={<Clock {...GLYPH} />}>
|
||||
{formatTourDuration(way.minutes)}
|
||||
</Figure>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { tourTarget } from './tourLook';
|
||||
import { useTakeProposal } from './useTourRouteCard';
|
||||
|
||||
@@ -58,7 +59,7 @@ export default function TourRouteActions({
|
||||
if (take.state === 'failed') caption = t('assistant.card.tour.askAgain');
|
||||
else if (take.state !== 'taken' && take.reasonKey) caption = t(take.reasonKey);
|
||||
return (
|
||||
<div className="mt-2.5 flex flex-col items-end gap-1">
|
||||
<CardFoot>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
@@ -92,10 +93,10 @@ export default function TourRouteActions({
|
||||
)}
|
||||
</div>
|
||||
{take.visible && caption && (
|
||||
<p className="m-0 text-end text-caption text-content-muted" role="status">
|
||||
<p className="m-0 basis-full text-end text-caption text-content-muted" role="status">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardFoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,11 +115,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-021: states the name, the figures as badges, the day and the hardest grade, uncertain when little is graded', () => {
|
||||
it('FE-AI-TOUR-021: states the name, the figures as framed fields, the day and the hardest grade, uncertain when little is graded', () => {
|
||||
const { container } = renderCard(HIKE);
|
||||
expect(screen.getByText('Hochfelln from Bergen')).toBeInTheDocument();
|
||||
for (const figure of ['time', 'distance', 'ascent', 'descent']) {
|
||||
expect(container.querySelector(`[data-figure="${figure}"]`)).not.toBeNull();
|
||||
expect(container.querySelector(`[data-field="${figure}"]`)).not.toBeNull();
|
||||
}
|
||||
expect(screen.getByText('8.2 km')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('640 m')).toHaveLength(2);
|
||||
@@ -159,7 +159,7 @@ describe('TourRouteCard', () => {
|
||||
|
||||
it('FE-AI-TOUR-024: a stop chip moves the spot in focus to the stop while pointed at', () => {
|
||||
renderCard(HIKE);
|
||||
const hut = screen.getByRole('button', { name: 'Strausshütte, 09:40' });
|
||||
const hut = screen.getByRole('button', { name: /^Strausshütte, 09:40/ });
|
||||
fireEvent.mouseEnter(hut);
|
||||
expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBeCloseTo(2400, 0);
|
||||
fireEvent.mouseLeave(hut);
|
||||
@@ -171,11 +171,11 @@ describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-024b: the stops run as a timeline, each walk on the line between two stops and read with the one it leads to', () => {
|
||||
renderCard(HIKE);
|
||||
const list = screen.getByRole('list', { name: 'Stops along the way' });
|
||||
// One row per stop; the lines between them are not rows.
|
||||
expect(list.querySelectorAll('[role="listitem"]')).toHaveLength(5);
|
||||
// One row per stop; the walks between them are hidden, read with the stop they lead to.
|
||||
expect(list.querySelectorAll('li:not([aria-hidden="true"])')).toHaveLength(5);
|
||||
// The place beside the way has no time: the hut measures from the start, then each stop from the last.
|
||||
expect(screen.getAllByText(/from the last stop$/)).toHaveLength(3);
|
||||
const legs = [...list.querySelectorAll('[aria-hidden="true"]')].map((leg) => leg.textContent).filter(Boolean);
|
||||
expect(screen.getAllByRole('button', { name: /from the last stop$/ })).toHaveLength(3);
|
||||
const legs = [...list.querySelectorAll('li[aria-hidden="true"]')].map((leg) => leg.textContent).filter(Boolean);
|
||||
expect(legs).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -234,18 +234,21 @@ describe('TourRouteCard', () => {
|
||||
const decide = vi.fn(async (_key: number, decisions: { toolCallId: string; approved: boolean }[]) => {
|
||||
// The server applies the take; the thread shows it applied with the new tour's place.
|
||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||
const applied = heldTake(true);
|
||||
applied.parts.push({
|
||||
// The server sends the answer back with the take joined to it, now applied with the new tour's place.
|
||||
const applied = proposed();
|
||||
applied.parts.push(...heldTake(true).parts, {
|
||||
type: 'data-trek-applied',
|
||||
data: { toolCallId: decisions[0]?.toolCallId ?? '', entity: 'tour', ids: [77], dayId: 22, undoUntil: null },
|
||||
});
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [proposed(), applied] } } });
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [applied] } } });
|
||||
});
|
||||
seed([proposed()]);
|
||||
useAssistantStore.setState({ decide });
|
||||
takeProposal.mockImplementationOnce(async () => {
|
||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [proposed(), heldTake(undefined)] } } });
|
||||
const joined = proposed();
|
||||
joined.parts.push(...heldTake(undefined).parts);
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [joined] } } });
|
||||
return 'held';
|
||||
});
|
||||
renderCard(HIKE, { onOpen });
|
||||
@@ -374,7 +377,7 @@ describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-042: the day shows the latest start when none is set, and the walk in the dark in red', () => {
|
||||
const schedule = HIKE.schedule as NonNullable<Route['schedule']>;
|
||||
renderCard({ ...HIKE, schedule: { ...schedule, start: null, arrive: null, latestStart: '10:30' } });
|
||||
expect(screen.getByText('Start by 10:30 to finish before sunset')).toBeInTheDocument();
|
||||
expect(screen.getByText('Start by 10:30')).toBeInTheDocument();
|
||||
|
||||
const { container } = renderCard({
|
||||
...HIKE,
|
||||
@@ -382,6 +385,6 @@ describe('TourRouteCard', () => {
|
||||
schedule: { ...schedule, start: '06:00', arrive: '19:00', latestStart: '10:30' },
|
||||
});
|
||||
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Start by 10:30 to finish before sunset')).toHaveLength(1);
|
||||
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import type { DistanceIndexedProfileSample } from '../../../../utils/routeGeometry';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import { CardSection } from '../parts/CardSection';
|
||||
import TourRouteActions from './TourRouteActions';
|
||||
import TourRouteDay from './TourRouteDay';
|
||||
import TourRouteHead from './TourRouteHead';
|
||||
import TourRouteHead, { TourRouteFields } from './TourRouteHead';
|
||||
import { profileSamples } from './tourRouteModel';
|
||||
import { TourRouteProblem, TourRouteWarnings } from './TourRouteNotes';
|
||||
import TourRouteProfile from './TourRouteProfile';
|
||||
@@ -40,19 +40,30 @@ export function TourRouteBody({
|
||||
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>;
|
||||
letter?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const samples = useMemo(() => profileSamples(card), [card]);
|
||||
return (
|
||||
<>
|
||||
<TourRouteProfile
|
||||
toolCallId={toolCallId}
|
||||
card={card}
|
||||
samples={samples}
|
||||
color={color}
|
||||
focus={route.focus}
|
||||
onFocus={route.setFocus}
|
||||
compare={compare}
|
||||
/>
|
||||
<TourRouteStops card={card} samples={samples} onFocus={route.setFocus} />
|
||||
<TourRouteFields card={card} />
|
||||
{/* A way drawn straight has neither heights nor the line to say so under: no section at all. */}
|
||||
{(samples.length >= 2 || card.routed) && (
|
||||
<CardSection label={t('tours.planner.inspector.elevation')}>
|
||||
<TourRouteProfile
|
||||
toolCallId={toolCallId}
|
||||
card={card}
|
||||
samples={samples}
|
||||
color={color}
|
||||
focus={route.focus}
|
||||
onFocus={route.setFocus}
|
||||
compare={compare}
|
||||
/>
|
||||
</CardSection>
|
||||
)}
|
||||
{card.stops.length > 0 && (
|
||||
<CardSection label={t('assistant.card.tour.stops')}>
|
||||
<TourRouteStops card={card} samples={samples} onFocus={route.setFocus} />
|
||||
</CardSection>
|
||||
)}
|
||||
<TourRouteDay card={card} />
|
||||
<TourRouteWarnings card={card} />
|
||||
<TourRouteProblem card={card} />
|
||||
@@ -63,12 +74,11 @@ export function TourRouteBody({
|
||||
|
||||
export default function TourRouteCard({ toolCallId, card }: { toolCallId: string; card: Route }) {
|
||||
const { t } = useTranslation();
|
||||
const { density } = useAssistantView();
|
||||
const route = useTourRouteCard(toolCallId, card);
|
||||
return (
|
||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<CardFrame label={routeName(card, t)}>
|
||||
<TourRouteHead card={card} sketchHeight={density === 'roomy' ? 64 : 56} onShow={route.showOnMap} />
|
||||
<TourRouteHead card={card} onShow={route.showOnMap} />
|
||||
<TourRouteBody toolCallId={toolCallId} card={card} route={route} color={variantPaintCss('current')} />
|
||||
</CardFrame>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createElement } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { weatherIconFor } from '../../../Weather/weatherIcons';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { type BarSpan, dayBarOf, forecastHours, RAIN_SHOWN_PCT } from './tourRouteDayModel';
|
||||
@@ -93,9 +94,13 @@ export default function TourRouteDay({ card }: { card: TourRouteCard }) {
|
||||
)}
|
||||
{!schedule.start && schedule.latestStart && (
|
||||
<div className="mt-1 flex">
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.tour.latestStart', { time: format.clock(schedule.latestStart) })}
|
||||
</SoftPill>
|
||||
<Tooltip label={t('assistant.card.tour.latestStart', { time: format.clock(schedule.latestStart) })}>
|
||||
<span className="inline-flex">
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.tour.latestStartShort', { time: format.clock(schedule.latestStart) })}
|
||||
</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{schedule.weather === 'climate' && (
|
||||
|
||||
@@ -4,12 +4,14 @@ import { createElement } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { fs, PILL } from '../../../shared/DialogShell';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
||||
import { CardFacts } from '../parts/CardFacts';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFields } from '../parts/CardSection';
|
||||
import RouteSketch from '../parts/RouteSketch';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
|
||||
@@ -89,7 +91,7 @@ export function TourGrade({ card }: { card: TourRouteCard }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The day a route is planned on, as words: "Day 2, Tue 14 Oct", or the date alone. */
|
||||
/** The day a route is planned on, as a white pill on the band: "Day 2, Tue 14 Oct", or the date alone. */
|
||||
function DayPill({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
@@ -98,7 +100,12 @@ function DayPill({ card }: { card: TourRouteCard }) {
|
||||
const date = format.date(schedule.date);
|
||||
const text =
|
||||
schedule.dayNumber !== null ? `${t('assistant.card.tour.day', { day: schedule.dayNumber })}, ${date}` : date;
|
||||
return <SoftPill icon={<CalendarDays size={10} strokeWidth={2.2} aria-hidden="true" />}>{text}</SoftPill>;
|
||||
return (
|
||||
<span className={PILL} style={fs(11)}>
|
||||
<CalendarDays size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The route drawn small: a button that shows it on the map. */
|
||||
@@ -127,35 +134,51 @@ export function TourRouteSketch({
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourRouteHead({
|
||||
card,
|
||||
sketchHeight,
|
||||
onShow,
|
||||
}: {
|
||||
card: TourRouteCard;
|
||||
sketchHeight: number;
|
||||
onShow: () => void;
|
||||
}) {
|
||||
/** The route's figures as framed fields, each under its label: distance, climb, descent and time, as known. */
|
||||
export function TourRouteFields({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const fields = factKeysOf(card).flatMap((key) => {
|
||||
const value = factValue(card, key);
|
||||
if (value === null) return [];
|
||||
// A field's label is short; how the time was reckoned is the figure badges' tooltip.
|
||||
const label = t(FACT_LOOK[key].labelKey ?? 'tours.planner.inspector.duration');
|
||||
let text: string;
|
||||
if (key === 'time') text = formatTourDuration(value);
|
||||
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
|
||||
return [{ key, label, value: text }];
|
||||
});
|
||||
return <CardFields fields={fields} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The head band of a route card: the route drawn small on a raised tile, what
|
||||
* kind of tour it is above its name, the label under it, and its grade and
|
||||
* the day it is planned on as pills on the band.
|
||||
*/
|
||||
export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; onShow: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const name = routeName(card, t);
|
||||
const tile = (
|
||||
<span className="w-12 flex-none overflow-hidden rounded-[10px] bg-surface-card p-1 shadow-sm">
|
||||
<TourRouteSketch card={card} height={48} onShow={onShow} />
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-[72px] flex-none">
|
||||
<TourRouteSketch card={card} height={sketchHeight} onShow={onShow} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<bdi title={name} className="line-clamp-2 min-w-0 flex-1 text-body font-semibold leading-tight text-content">
|
||||
{name}
|
||||
</bdi>
|
||||
<TourGrade card={card} />
|
||||
</div>
|
||||
{card.label && <p className="m-0 mt-0.5 truncate text-caption text-content-muted">{card.label}</p>}
|
||||
<CardFacts>
|
||||
<TourRouteFigures card={card} />
|
||||
<CardHeader
|
||||
icon={tourDayRowIcon(card.activity)}
|
||||
tone="info"
|
||||
tile={tile}
|
||||
eyebrow={t(TOUR_TYPE_WORDS[card.activity].labelKey)}
|
||||
title={name}
|
||||
meta={card.label ?? undefined}
|
||||
>
|
||||
{((card.activity === 'hike' && card.grades) || card.schedule) && (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
{card.activity === 'hike' && card.grades && <TourGrade card={card} />}
|
||||
<DayPill card={card} />
|
||||
</CardFacts>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { verbLookOf } from '../cardLooks';
|
||||
import KindTile from '../parts/KindTile';
|
||||
import { TourGrade, TourRouteFigures, TourRouteSketch } from './TourRouteHead';
|
||||
import WayFigures from './TourFigureLine';
|
||||
import { TourGrade, TourRouteSketch } from './TourRouteHead';
|
||||
import { legVariantOf, previousVariantOf, profileBands, profileSamples } from './tourRouteModel';
|
||||
import { useTourRouteCard } from './useTourRouteCard';
|
||||
|
||||
@@ -37,29 +38,33 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
|
||||
const samples = useMemo(() => profileSamples(card), [card]);
|
||||
const previous = previousVariantOf(card);
|
||||
const notPlanned = card.problem?.why === 'not_planned';
|
||||
const grades = card.activity === 'hike' ? card.grades : null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5" data-testid="tour-route-row">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-16 flex-none">
|
||||
<TourRouteSketch card={card} height={40} onShow={showOnMap} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<TourRouteFigures card={card} />
|
||||
<TourGrade card={card} />
|
||||
{previous && (
|
||||
<VariantDelta
|
||||
variant={legVariantOf(card, 'current')}
|
||||
current={previous}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaBefore')}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-secondary p-2.5"
|
||||
data-testid="tour-route-row"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-14 flex-none overflow-hidden rounded-[10px] bg-surface-card p-1 shadow-sm">
|
||||
<TourRouteSketch card={card} height={36} onShow={showOnMap} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<WayFigures way={legVariantOf(card, 'current')} unit={unit} />
|
||||
</span>
|
||||
{grades && grades.highest !== null && <TourGrade card={card} />}
|
||||
</span>
|
||||
{notPlanned && (
|
||||
<p className="m-0 mt-1 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>
|
||||
{previous && (
|
||||
<VariantDelta
|
||||
variant={legVariantOf(card, 'current')}
|
||||
current={previous}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaBefore')}
|
||||
/>
|
||||
)}
|
||||
{notPlanned && <p className="m-0 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{!notPlanned && samples.length >= 2 && (
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function TourRouteProfile({ toolCallId, card, samples, color, foc
|
||||
const bands = useMemo(() => profileBands(card, samples), [card, samples]);
|
||||
if (samples.length < 2) {
|
||||
if (!card.routed) return null;
|
||||
return <p className="m-0 mt-2 text-caption text-content-faint">{t('assistant.card.tour.noHeights')}</p>;
|
||||
return <p className="m-0 text-caption text-content-faint">{t('assistant.card.tour.noHeights')}</p>;
|
||||
}
|
||||
const phone = variant === 'fullscreen';
|
||||
const focusGrade = focus ? gradeAt(grades, focus.distanceMeters) : null;
|
||||
@@ -112,7 +112,7 @@ export default function TourRouteProfile({ toolCallId, card, samples, color, foc
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="rounded-[12px] border border-edge-faint bg-surface-secondary px-2 pb-1.5 pt-2">
|
||||
<ElevationProfile
|
||||
samples={samples}
|
||||
color={color}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
|
||||
import { AlertTriangle, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
|
||||
import { AlertTriangle, Clock, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type RouteProfileFocus,
|
||||
} from '../../../../utils/routeGeometry';
|
||||
import { TOUR_POI_LOOKS } from '../../../Map/poiCategories';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { StageEndCorner } from '../../../Tours/stages/StageParts';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
@@ -18,12 +18,13 @@ import { FLAG_LOOK, stopName } from './tourRouteWords';
|
||||
|
||||
/**
|
||||
* The stops of a route as a timeline in the order they lie along it: one row
|
||||
* per stop (the start and the end, the huts, summits and stations on the way
|
||||
* with their clock, what lies a little off the way quieter), joined by a line
|
||||
* that carries the walking time of the stretch between two stops, so a time
|
||||
* is never read as belonging to the stop beside it. Pointing at a chip or
|
||||
* tapping it moves the spot in focus there, on the profile and on the map
|
||||
* alike. Past eight the middle ones fold into a count that names them.
|
||||
* per stop on a line down the card (the start and the end filled, the huts,
|
||||
* summits and stations on the way with their glyph, what lies a little off
|
||||
* the way quieter), its name, how far off the way or how long the stay under
|
||||
* it, and its clock at the end; the walk between two stops sits on the line
|
||||
* between their rows, so a time is never read as a stop's own. Pointing at a
|
||||
* row or tapping it moves the spot in focus there, on the profile and on the
|
||||
* map alike. Past eight the middle ones fold into a row that names them.
|
||||
*/
|
||||
|
||||
/** A stop's glyph: the flag at either end, the mountain at the highest point, the place's own for a kind. */
|
||||
@@ -46,12 +47,31 @@ function focusOf(stop: TourRouteStop, samples: readonly DistanceIndexedProfileSa
|
||||
);
|
||||
}
|
||||
|
||||
function StopChip({
|
||||
/** A stop's mark on the line: filled at the start and the end, a ring with its glyph on the way, smaller off it. */
|
||||
function Marker({ stop }: { stop: TourRouteStop }) {
|
||||
const end = stop.role === 'start' || stop.role === 'end';
|
||||
const along = stop.role === 'along';
|
||||
const glyph = createElement(iconOf(stop), { size: along ? 9 : 10, strokeWidth: 2.2, 'aria-hidden': true });
|
||||
let look = 'h-5 w-5 bg-surface-card text-content-muted ring-1 ring-inset ring-edge';
|
||||
if (end) look = 'h-5 w-5 bg-accent text-accent-text';
|
||||
else if (along) look = 'h-4 w-4 bg-surface-secondary text-content-faint ring-1 ring-inset ring-edge-faint';
|
||||
return (
|
||||
<span className="relative z-[1] flex w-5 flex-none justify-center">
|
||||
<span className={`grid place-items-center rounded-full ${look}`}>{glyph}</span>
|
||||
{stop.stageEnd && <StageEndCorner />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StopRow({
|
||||
stop,
|
||||
leg,
|
||||
samples,
|
||||
onFocus,
|
||||
}: {
|
||||
stop: TourRouteStop;
|
||||
/** The walk from the last stop with a time to this one, read with it. */
|
||||
leg: number | null;
|
||||
samples: readonly DistanceIndexedProfileSample[];
|
||||
onFocus: (focus: RouteProfileFocus | null) => void;
|
||||
}) {
|
||||
@@ -65,44 +85,45 @@ function StopChip({
|
||||
: null;
|
||||
const stay = stop.stayMinutes ? t('assistant.card.tour.stay', { time: format.minutes(stop.stayMinutes) }) : null;
|
||||
const clock = stop.clock ? format.clock(stop.clock) : null;
|
||||
const label = [name, clock, off, stay].filter(Boolean).join(', ');
|
||||
const glyph = createElement(iconOf(stop), {
|
||||
size: 10,
|
||||
strokeWidth: 2.2,
|
||||
'aria-hidden': true,
|
||||
className: 'flex-none text-content-faint',
|
||||
});
|
||||
const body = (
|
||||
<>
|
||||
<bdi className="max-w-[10rem] truncate">{name}</bdi>
|
||||
{clock && <span className="tabular-nums text-content-muted">{clock}</span>}
|
||||
{off && <span className="text-content-faint">{off}</span>}
|
||||
</>
|
||||
);
|
||||
const walked = leg !== null ? t('assistant.card.tour.legTime', { time: format.minutes(leg) }) : null;
|
||||
const label = [name, clock, off, stay, walked].filter(Boolean).join(', ');
|
||||
const point = () => onFocus(focusOf(stop, samples));
|
||||
const under = [off, stay].filter((part): part is string => part !== null);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onMouseEnter={() => onFocus(focusOf(stop, samples))}
|
||||
onMouseLeave={() => onFocus(null)}
|
||||
onFocus={() => onFocus(focusOf(stop, samples))}
|
||||
onBlur={() => onFocus(null)}
|
||||
onClick={() => onFocus(focusOf(stop, samples))}
|
||||
className="relative inline-flex rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{along ? <WhiteBadge icon={glyph}>{body}</WhiteBadge> : <SoftPill icon={glyph}>{body}</SoftPill>}
|
||||
{stop.stageEnd && <StageEndCorner />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{stay && <span className="text-caption text-content-faint">{stay}</span>}
|
||||
{stop.flag && (
|
||||
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t(FLAG_LOOK[stop.flag].labelKey)}
|
||||
</SoftPill>
|
||||
)}
|
||||
</span>
|
||||
<li className="flex">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onMouseEnter={point}
|
||||
onMouseLeave={() => onFocus(null)}
|
||||
onFocus={point}
|
||||
onBlur={() => onFocus(null)}
|
||||
onClick={point}
|
||||
className="-mx-1.5 flex min-w-0 flex-1 items-center gap-2.5 rounded-[10px] px-1.5 py-1 text-start transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Marker stop={stop} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<bdi
|
||||
className={`block truncate leading-tight ${along ? 'text-caption text-content-secondary' : 'text-body font-medium text-content'}`}
|
||||
>
|
||||
{name}
|
||||
</bdi>
|
||||
{(under.length > 0 || stop.flag) && (
|
||||
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-caption text-content-faint">
|
||||
{under.map((part) => (
|
||||
<span key={part}>{part}</span>
|
||||
))}
|
||||
{stop.flag && (
|
||||
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t(FLAG_LOOK[stop.flag].labelKey)}
|
||||
</SoftPill>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{clock && <span className="flex-none text-caption tabular-nums text-content-muted">{clock}</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,38 +133,36 @@ function legMinutes(from: TourRouteStop | undefined, to: TourRouteStop): number
|
||||
return to.minutes - from.minutes;
|
||||
}
|
||||
|
||||
/** The line between two rows, with the stretch's walking time on it. */
|
||||
/** The walk between two rows, beside the line: read with the row it leads to, so hidden here. */
|
||||
function Leg({ minutes }: { minutes: number | null }) {
|
||||
const format = useCardFormat();
|
||||
if (minutes === null) return <li aria-hidden="true" className="h-1.5" />;
|
||||
return (
|
||||
// Read with the stop it leads to; a list holds only its rows.
|
||||
<span aria-hidden="true" className="flex min-h-[14px] items-stretch gap-2">
|
||||
<span className="flex w-2.5 flex-none justify-center">
|
||||
<span className="w-px bg-edge" />
|
||||
<li aria-hidden="true" className="flex items-center py-0.5 ps-[30px]">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-caption tabular-nums text-content-muted">
|
||||
<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />
|
||||
{format.minutes(minutes)}
|
||||
</span>
|
||||
{minutes !== null && (
|
||||
<span className="text-caption tabular-nums leading-tight text-content-faint">{format.minutes(minutes)}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row of the timeline: its dot on the line, then the stop, read after the walk that leads to it. */
|
||||
function Row({ end, leg, children }: { end: boolean; leg?: number | null; children: ReactNode }) {
|
||||
/** The middle stops folded into one row past eight, naming them in its tooltip. */
|
||||
function FoldedRow({ stops }: { stops: readonly TourRouteStop[] }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const names = stops.map((stop) => stopName(stop, t)).join(', ');
|
||||
return (
|
||||
<span role="listitem" className="flex items-center gap-2">
|
||||
{leg != null && (
|
||||
<span className="sr-only">{t('assistant.card.tour.legTime', { time: format.minutes(leg) })}</span>
|
||||
)}
|
||||
<span className="flex w-2.5 flex-none justify-center">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${end ? 'bg-content-muted' : 'border border-content-faint bg-surface-card'}`}
|
||||
/>
|
||||
<li className="flex items-center gap-2.5 py-1">
|
||||
<span className="relative z-[1] flex w-5 flex-none justify-center">
|
||||
<span className="h-2 w-2 rounded-full border border-dashed border-content-faint bg-surface-card" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">{children}</span>
|
||||
</span>
|
||||
<Tooltip label={names}>
|
||||
<span className="text-caption text-content-muted underline decoration-dotted underline-offset-2">
|
||||
{t('assistant.card.tour.stopsMore', { count: stops.length })}
|
||||
<span className="sr-only">{`: ${names}`}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,42 +178,30 @@ export default function TourRouteStops({
|
||||
const { t } = useTranslation();
|
||||
const { head, folded, tail } = chipStops(card);
|
||||
if (head.length === 0) return null;
|
||||
const names = folded.map((stop) => stopName(stop, t)).join(', ');
|
||||
// The time is walked from the last stop that has one: a place beside the way has none.
|
||||
let walked: TourRouteStop | undefined;
|
||||
const rows: ReactNode[] = [];
|
||||
const pushStop = (stop: TourRouteStop, key: string) => {
|
||||
const leg = legMinutes(walked, stop);
|
||||
if (rows.length > 0) rows.push(<Leg key={`${key}-leg`} minutes={leg} />);
|
||||
rows.push(
|
||||
<Row key={key} end={stop.role === 'start' || stop.role === 'end'} leg={leg}>
|
||||
<StopChip stop={stop} samples={samples} onFocus={onFocus} />
|
||||
</Row>
|
||||
);
|
||||
rows.push(<StopRow key={key} stop={stop} leg={leg} samples={samples} onFocus={onFocus} />);
|
||||
if (stop.minutes !== null) walked = stop;
|
||||
};
|
||||
head.forEach((stop, index) => pushStop(stop, `h-${index}`));
|
||||
if (folded.length > 0) {
|
||||
rows.push(<Leg key="f-leg" minutes={null} />);
|
||||
rows.push(
|
||||
<Row key="f" end={false}>
|
||||
<Tooltip label={names}>
|
||||
<span className="inline-flex">
|
||||
<WhiteBadge>
|
||||
{t('assistant.card.tour.stopsMore', { count: folded.length })}
|
||||
<span className="sr-only">{`: ${names}`}</span>
|
||||
</WhiteBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Row>
|
||||
);
|
||||
rows.push(<FoldedRow key="f" stops={folded} />);
|
||||
// The folded stretch's time is not one leg; the next stop measures from the last folded one.
|
||||
walked = [...folded].reverse().find((stop) => stop.minutes !== null) ?? walked;
|
||||
}
|
||||
tail.forEach((stop, index) => pushStop(stop, `t-${index}`));
|
||||
return (
|
||||
<div className="mt-2 flex flex-col" role="list" aria-label={t('assistant.card.tour.stops')}>
|
||||
{rows}
|
||||
<div className="relative">
|
||||
{/* The line down the timeline, from the first mark to the last, under the marks. */}
|
||||
<span aria-hidden="true" className="absolute bottom-3 start-[9.5px] top-3 w-px bg-edge" />
|
||||
<ul className="relative m-0 flex list-none flex-col p-0" aria-label={t('assistant.card.tour.stops')}>
|
||||
{rows}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,128 @@
|
||||
import type { TourRouteCard as Route } from '@trek/shared';
|
||||
import { Route as RouteIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import type { DistanceUnit } from '../../../../types';
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { VariantDelta } from '../../../Tours/planner/LegVariantsCard';
|
||||
import { VariantFigures, WayChoice } from '../../../Tours/planner/mapCardParts';
|
||||
import { type LegVariant, variantDelta } from '../../../Tours/planner/legVariants';
|
||||
import { VariantMark } from '../../../Tours/planner/mapCardParts';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardSection } from '../parts/CardSection';
|
||||
import WayFigures from './TourFigureLine';
|
||||
import { TourRouteBody } from './TourRouteCard';
|
||||
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
|
||||
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
|
||||
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
|
||||
|
||||
/** The ways of one answer with the chosen one known: its choices, then the chosen way's body. */
|
||||
/** A signed difference: a plus for more, a minus sign for less, nothing for none worth printing. */
|
||||
function signed(value: number, format: (absolute: number) => string): string | null {
|
||||
const text = format(Math.abs(value));
|
||||
if (Math.abs(value) < 1e-9 || /^0\s/.test(text)) return null;
|
||||
return `${value > 0 ? '+' : '−'}${text}`;
|
||||
}
|
||||
|
||||
/** How a way differs from the chosen one, as one faint line: "Compared with way A: −4.3 km −178 m −1 h 33 min". */
|
||||
function WayDelta({
|
||||
way,
|
||||
chosen,
|
||||
letter,
|
||||
unit,
|
||||
}: {
|
||||
way: LegVariant;
|
||||
chosen: LegVariant;
|
||||
letter: string;
|
||||
unit: DistanceUnit;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const delta = variantDelta(way, chosen);
|
||||
const parts = [
|
||||
signed(delta.distanceM / 1000, (km) => formatDistance(km, unit)),
|
||||
delta.gainM === null ? null : signed(delta.gainM, (m) => formatElevation(m, unit)),
|
||||
delta.minutes === null ? null : signed(delta.minutes, formatTourDuration),
|
||||
].filter((part): part is string => part !== null);
|
||||
if (parts.length === 0) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex flex-wrap items-center gap-x-2 text-caption text-content-faint"
|
||||
data-testid="leg-variant-delta"
|
||||
>
|
||||
<span>{t('assistant.card.tour.deltaTo', { letter })}</span>
|
||||
{parts.map((part) => (
|
||||
<span key={part} className="tabular-nums text-content-muted">
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One way to choose: its letter in the colour it is drawn in, its name and grade, its figures, how it differs. */
|
||||
function WayOption({
|
||||
entry,
|
||||
index,
|
||||
chosen,
|
||||
chosenWay,
|
||||
chosenLetter,
|
||||
unit,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: {
|
||||
entry: RouteEntry;
|
||||
index: number;
|
||||
chosen: boolean;
|
||||
chosenWay: LegVariant;
|
||||
chosenLetter: string;
|
||||
unit: DistanceUnit;
|
||||
onSelect: () => void;
|
||||
onHover: (on: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const key = WAY_KEYS[index] ?? 'a';
|
||||
const letter = key.toUpperCase();
|
||||
const way = legVariantOf(entry.card, key);
|
||||
const grades = entry.card.grades;
|
||||
const name = entry.card.label ?? t('tours.variants.option', { letter });
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chosen}
|
||||
aria-label={name}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => onHover(true)}
|
||||
onMouseLeave={() => onHover(false)}
|
||||
onFocus={() => onHover(true)}
|
||||
onBlur={() => onHover(false)}
|
||||
className={`flex w-full items-start gap-2.5 rounded-[12px] border px-2.5 py-2 text-start transition-colors ${
|
||||
chosen
|
||||
? 'border-accent bg-surface-card shadow-sm'
|
||||
: 'border-edge-faint bg-surface-secondary hover:border-edge hover:bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
<VariantMark variant={way} chosen={chosen} />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<bdi className="min-w-0 flex-1 truncate text-body font-semibold leading-tight text-content">{name}</bdi>
|
||||
{grades && grades.highest !== null && (
|
||||
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
|
||||
)}
|
||||
</span>
|
||||
<WayFigures way={way} unit={unit} />
|
||||
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The ways of one answer with the chosen one known: the band, the choices, then the chosen way's body. */
|
||||
function WaysCard({
|
||||
entries,
|
||||
chosen,
|
||||
@@ -44,57 +153,49 @@ function WaysCard({
|
||||
[entries, chosenIndex]
|
||||
);
|
||||
|
||||
const current = legVariantOf(chosen.card, chosenKey);
|
||||
const chosenWay = legVariantOf(chosen.card, chosenKey);
|
||||
// Side by side where the window is roomy; on the phone the ways stay rows, so a finger scrubs the profile freely.
|
||||
let columns = '';
|
||||
if (density === 'roomy' && variant !== 'fullscreen') columns = entries.length >= 3 ? 'grid-cols-3' : 'grid-cols-2';
|
||||
const title = t('assistant.card.tour.ways', { count: entries.length });
|
||||
const card: Route = chosen.card;
|
||||
return (
|
||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<div {...route.handlers} className={`rounded-[16px] ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<CardFrame label={title}>
|
||||
<p className="m-0 text-caption text-content-muted">{title}</p>
|
||||
<div role="radiogroup" aria-label={title} className={`mt-1.5 grid gap-1.5 ${columns}`}>
|
||||
{entries.map((entry, index) => {
|
||||
const key = WAY_KEYS[index] ?? 'a';
|
||||
const letter = key.toUpperCase();
|
||||
const way = legVariantOf(entry.card, key);
|
||||
const grades = entry.card.grades;
|
||||
return (
|
||||
<WayChoice
|
||||
<CardHeader
|
||||
icon={RouteIcon}
|
||||
tone="info"
|
||||
eyebrow={t(TOUR_TYPE_WORDS[card.activity].labelKey)}
|
||||
title={routeName(card, t)}
|
||||
meta={title}
|
||||
/>
|
||||
<CardSection label={t('assistant.card.tour.waysPick')}>
|
||||
<div role="radiogroup" aria-label={title} className={`grid gap-1.5 ${columns}`}>
|
||||
{entries.map((entry, index) => (
|
||||
<WayOption
|
||||
key={entry.toolCallId}
|
||||
way={way}
|
||||
name={entry.card.label ?? t('tours.variants.option', { letter })}
|
||||
entry={entry}
|
||||
index={index}
|
||||
chosen={index === chosenIndex}
|
||||
aside={
|
||||
<>
|
||||
{index !== chosenIndex && (
|
||||
<VariantDelta
|
||||
variant={way}
|
||||
current={current}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaTo', { letter: chosenKey.toUpperCase() })}
|
||||
/>
|
||||
)}
|
||||
{grades && <SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />}
|
||||
</>
|
||||
}
|
||||
chosenWay={chosenWay}
|
||||
chosenLetter={chosenKey.toUpperCase()}
|
||||
unit={unit}
|
||||
onSelect={() => pick(tripId, blockKey, entry.toolCallId)}
|
||||
onHover={(on) => hover(entry.toolCallId, on)}
|
||||
>
|
||||
<VariantFigures variant={way} unit={unit} t={t} />
|
||||
</WayChoice>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardSection>
|
||||
<div className="mt-3">
|
||||
<TourRouteBody
|
||||
toolCallId={chosen.toolCallId}
|
||||
card={chosen.card}
|
||||
route={route}
|
||||
color={variantPaintCss(chosenKey)}
|
||||
compare={compare}
|
||||
letter={chosenKey.toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<TourRouteBody
|
||||
toolCallId={chosen.toolCallId}
|
||||
card={chosen.card}
|
||||
route={route}
|
||||
color={variantPaintCss(chosenKey)}
|
||||
compare={compare}
|
||||
letter={chosenKey.toUpperCase()}
|
||||
/>
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
@@ -102,12 +203,12 @@ function WaysCard({
|
||||
|
||||
/**
|
||||
* Two or three ways of one route an answer proposed, as the planner offers a
|
||||
* section's ways: each a choice with its letter in the colour it is drawn in,
|
||||
* its own words, how it differs from the chosen one and its hardest grade,
|
||||
* its figures below. One profile holds them all, the chosen way filled and
|
||||
* the others thin on the same axes; under it the chosen way's stops, day and
|
||||
* warnings, and "Plan way B". A choice is kept per answer, and pointing at a
|
||||
* way lights its line on the map.
|
||||
* section's ways: a band naming the route, each way a choice with its letter
|
||||
* in the colour it is drawn in, its name and hardest grade, its figures and
|
||||
* how it differs from the chosen one; one profile holds them all, the chosen
|
||||
* way filled and the others thin on the same axes; under it the chosen way's
|
||||
* figures, stops, day and warnings, and "Plan way B". A choice is kept per
|
||||
* answer, and pointing at a way lights its line on the map.
|
||||
*/
|
||||
export default function TourWays({ entries }: { entries: readonly RouteEntry[] }) {
|
||||
const { tripId } = useAssistantView();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
|
||||
import { Route } from 'lucide-react';
|
||||
import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
|
||||
|
||||
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
|
||||
import type { EntityTarget, PackLook } from '../cardTypes';
|
||||
@@ -86,6 +86,15 @@ export const TOUR_LOOK = {
|
||||
verbs: {
|
||||
addTour: { Icon: Route, tone: 'success', labelKey: 'assistant.change.verb.addTour' },
|
||||
updateTour: { Icon: Route, tone: 'info', labelKey: 'assistant.change.verb.updateTour' },
|
||||
markVisited: { Icon: CircleCheck, tone: 'success', labelKey: 'assistant.change.verb.markVisited' },
|
||||
markSkipped: { Icon: CircleSlash, tone: 'info', labelKey: 'assistant.change.verb.markSkipped' },
|
||||
markPlanned: { Icon: Circle, tone: 'info', labelKey: 'assistant.change.verb.markPlanned' },
|
||||
assignTrack: { Icon: Footprints, tone: 'success', labelKey: 'assistant.change.verb.assignTrack' },
|
||||
unassignTrack: { Icon: Footprints, tone: 'danger', labelKey: 'assistant.change.verb.unassignTrack' },
|
||||
excludeTour: { Icon: ChartNoAxesColumn, tone: 'info', labelKey: 'assistant.change.verb.excludeTour' },
|
||||
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
|
||||
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
|
||||
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
|
||||
},
|
||||
tools: {},
|
||||
steps: {
|
||||
@@ -96,6 +105,7 @@ export const TOUR_LOOK = {
|
||||
list_tours: { stepKey: 'assistant.step.readingTours' },
|
||||
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
|
||||
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
|
||||
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
|
||||
},
|
||||
entities: {
|
||||
tour: {
|
||||
|
||||
@@ -67,6 +67,9 @@ describe('takeInThread', () => {
|
||||
it('FE-AI-TOUR-048: a take that names its proposal is matched by it, not by its way', () => {
|
||||
const other = { ...GENTLE, line: [[47.9, 12.8, 600], ...GENTLE.line.slice(1)] } satisfies TourRouteCard;
|
||||
expect(takeInThread([proposal, take(other, {}, 'take_proposal', 'call_1')], 'call_1', HIKE)).toBe('waiting');
|
||||
// The server joins a take to the answer that drew the proposal: found there too.
|
||||
const joined = { ...proposal, parts: [...proposal.parts, ...take(other, {}, 'take_proposal', 'call_1').parts] };
|
||||
expect(takeInThread([joined], 'call_1', HIKE)).toBe('waiting');
|
||||
// The same way taken from another proposal leaves this one free.
|
||||
expect(
|
||||
takeInThread([proposal, take(HIKE, { approved: true }, 'take_proposal', 'call_2')], 'call_1', HIKE)
|
||||
|
||||
@@ -45,7 +45,8 @@ export function takeOfProposal(
|
||||
): { id: string; state: 'waiting' | 'applied'; placeId: number | null } | null {
|
||||
const from = proposalIndex(messages, toolCallId);
|
||||
if (from < 0) return null;
|
||||
for (const message of messages.slice(from + 1)) {
|
||||
// The server joins a take to the answer that drew its proposal, so that answer is searched as well as the later ones.
|
||||
for (const message of messages.slice(from)) {
|
||||
const open = new Map<string, 'waiting' | 'applied'>();
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool-call' || part.toolName !== TAKE_TOOL || part.approval?.resolution) continue;
|
||||
|
||||
@@ -14,6 +14,6 @@ import TourRouteInRow, { TourRouteLead } from './TourRouteInRow';
|
||||
* file.
|
||||
*/
|
||||
export const TOUR_VIEWS = {
|
||||
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow },
|
||||
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow, wideBody: true },
|
||||
tourList: { Block: TourListCard },
|
||||
} satisfies { [K in 'tourRoute' | 'tourList']: CardView<K> };
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { Palmtree } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { safeHexColor } from '../../../../utils/safeColor';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { useViewerId } from '../parts/useViewerId';
|
||||
import VacayBalance from './VacayBalance';
|
||||
import { balanceLines, mergeVacayCards, type VacayView } from './vacayCardModel';
|
||||
@@ -26,7 +30,6 @@ const SQUARE = 'size-2 flex-none rounded-sm';
|
||||
const COMPANY = 'size-2 flex-none rounded-sm bg-warning-soft ring-1 ring-inset ring-warning';
|
||||
const TRIP = 'size-1.5 flex-none rounded-full bg-info';
|
||||
const OTHER = 'mt-1.5 text-caption text-content-muted';
|
||||
const FOOT = 'mt-1 flex justify-end';
|
||||
|
||||
interface LegendItem {
|
||||
key: string;
|
||||
@@ -79,13 +82,14 @@ export default function VacayAnswerBlock({ cards }: { cards: readonly CardEntry[
|
||||
const first = own[0] ?? null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.vacay.label')}>
|
||||
<CardHeader icon={Palmtree} tone="info" title={t('assistant.card.vacay.label')} />
|
||||
{view.months.length > 0 && <VacayMonthStrip view={view} placed={[]} labelOf={() => ''} />}
|
||||
<Legend view={view} />
|
||||
{view.otherDays > 0 && <p className={OTHER}>{t('assistant.card.vacay.otherDays', { count: view.otherDays })}</p>}
|
||||
<VacayBalance lines={lines} view={view} mode="read" />
|
||||
<div className={FOOT}>
|
||||
<CardFoot>
|
||||
<CardOpenButton target={{ kind: 'page', path: vacayPathOf(first) }} labelKey="assistant.card.vacay.open" />
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* FE-AI-HOME-001 to FE-AI-HOME-004
|
||||
*
|
||||
* The home chat's shared hooks: its key and name, the way into one trip's own
|
||||
* chat with the question waiting there (a question already typed there
|
||||
* stays), the way in from the pages outside a trip, and the questions the
|
||||
* empty home chat offers.
|
||||
*/
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { HOME_SUGGESTIONS } from './assistantModel';
|
||||
import { useAssistantHome, useAssistantHomeLauncher } from './useAssistantHome';
|
||||
import { useAssistantSuggestions } from './useAssistantSuggestions';
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
});
|
||||
|
||||
function Home({ switchTo }: { switchTo: (trip: { id: number; title: string }) => void }) {
|
||||
const home = useAssistantHome(switchTo);
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
chat {home.chat.id} {home.chat.title}
|
||||
</p>
|
||||
<button type="button" onClick={() => home.openTripChat({ id: 9, title: 'Lisbon' }, 'What is missing?')}>
|
||||
ask
|
||||
</button>
|
||||
<button type="button" onClick={() => home.openTripChat({ id: 9, title: 'Lisbon' }, null)}>
|
||||
go
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Launcher() {
|
||||
const { available, open } = useAssistantHomeLauncher();
|
||||
return available ? (
|
||||
<button type="button" onClick={open}>
|
||||
open
|
||||
</button>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function Suggestions() {
|
||||
const { suggestions, canEdit } = useAssistantSuggestions(0);
|
||||
return (
|
||||
<ul data-can-edit={String(canEdit)}>
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.id}>{s.prompt}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useAssistantHome', () => {
|
||||
it('FE-AI-HOME-001: names the home chat and keeps it under its own key', () => {
|
||||
render(<Home switchTo={vi.fn()} />);
|
||||
expect(screen.getByText('chat 0 Your travels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FE-AI-HOME-002: goes on in a trip's chat with the question waiting, and never over one typed there", () => {
|
||||
const switchTo = vi.fn();
|
||||
render(<Home switchTo={switchTo} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ask' }));
|
||||
expect(switchTo).toHaveBeenCalledWith({ id: 9, title: 'Lisbon' });
|
||||
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('What is missing?');
|
||||
|
||||
useAssistantStore.getState().setDraft(9, 'My own words');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ask' }));
|
||||
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('My own words');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'go' }));
|
||||
expect(switchTo).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAssistantHomeLauncher', () => {
|
||||
it('FE-AI-HOME-003: offers the way in only while the assistant can run, and opens the window', () => {
|
||||
const { rerender } = render(<Launcher />);
|
||||
expect(screen.queryByRole('button', { name: 'open' })).not.toBeInTheDocument();
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse });
|
||||
rerender(<Launcher />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open' }));
|
||||
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the empty home chat', () => {
|
||||
it('FE-AI-HOME-004: offers questions across the trips, all of them, and holds nothing back', () => {
|
||||
render(<Suggestions />);
|
||||
const items = screen.getAllByRole('listitem').map((item) => item.textContent);
|
||||
expect(items).toHaveLength(HOME_SUGGESTIONS.length);
|
||||
expect(items).toContain('Which trip is next, and what is still open?');
|
||||
expect(items).toContain('How many countries have I visited?');
|
||||
expect(screen.getByRole('list')).toHaveAttribute('data-can-edit', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { HOME_CHAT_KEY } from '../../store/assistantChatKey';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import type { AssistantChatTrip } from './assistantViewContext';
|
||||
|
||||
/**
|
||||
* The home chat, the assistant outside any one trip, as the desktop window
|
||||
* and the phone screen both show it: the key its conversations are kept
|
||||
* under, the name its head carries, and the way into one trip's own chat.
|
||||
* That way leaves the question the home chat handed over waiting in the
|
||||
* trip's composer, ready to send, and then lets the view switch (the window
|
||||
* shows that trip's chat in place, the phone opens the trip with its chat).
|
||||
*/
|
||||
export function useAssistantHome(switchTo: (trip: AssistantChatTrip) => void) {
|
||||
const { t } = useTranslation();
|
||||
const setDraft = useAssistantStore((s) => s.setDraft);
|
||||
|
||||
const openTripChat = useCallback(
|
||||
(trip: AssistantChatTrip, question: string | null) => {
|
||||
// A question typed there already stays; the handed-over one only fills an empty composer.
|
||||
const typed = useAssistantStore.getState().byTrip[trip.id]?.draft ?? '';
|
||||
if (question && !typed.trim()) setDraft(trip.id, question);
|
||||
switchTo(trip);
|
||||
},
|
||||
[setDraft, switchTo]
|
||||
);
|
||||
|
||||
const title = t('assistant.home.title');
|
||||
const chat = useMemo((): AssistantChatTrip => ({ id: HOME_CHAT_KEY, title }), [title]);
|
||||
return { chat, openTripChat };
|
||||
}
|
||||
|
||||
/**
|
||||
* The way into the home chat on the pages outside a trip (the phone's
|
||||
* dashboard head and the dock's More list): offered only while the assistant
|
||||
* can run for this user, like every other way in.
|
||||
*/
|
||||
export function useAssistantHomeLauncher() {
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.open);
|
||||
return { available, open };
|
||||
}
|
||||
@@ -1,38 +1,18 @@
|
||||
import { type AssistantFoundPlace, categoryIdForKind } from '@trek/shared';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useCanDo } from '../../store/permissionsStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Assignment } from '../../types';
|
||||
import { dayLabel } from '../../utils/dayLabel';
|
||||
import { haversineKm } from '../../utils/geo';
|
||||
import { safeHexColor } from '../../utils/safeColor';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
import { hasUndecided } from './assistantParts';
|
||||
import { useAssistantScope, useAssistantView } from './assistantViewContext';
|
||||
import { useCardPoint } from './cards/parts/useCardPoint';
|
||||
|
||||
/** A day a found place can be suggested for, named the way the day list names it. */
|
||||
interface StripDay {
|
||||
id: number;
|
||||
/** Its place in the trip, 1 for the first day: "+ Day 3". */
|
||||
n: number;
|
||||
label: string;
|
||||
/** Its weekday, short ("Sat"), for the day grid; null for a day without a date. */
|
||||
weekday: string | null;
|
||||
}
|
||||
|
||||
/** The short weekday of a `YYYY-MM-DD` date, read as that calendar day wherever the reader is. */
|
||||
function weekdayOf(date: string | null | undefined, locale: string): string | null {
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
return new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`));
|
||||
}
|
||||
|
||||
/** The trip category a found place most likely belongs in, for its line and its icon. */
|
||||
interface StripCategory {
|
||||
name: string;
|
||||
@@ -54,20 +34,17 @@ function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng:
|
||||
|
||||
/**
|
||||
* What the strip of found places needs, the same for the window and the
|
||||
* phone: the trip's days to suggest a place for, the day in focus, how far a
|
||||
* place is from that day's last stop, its likely category, whether a
|
||||
* suggestion can be made now and why not, and the map's part (the pin while
|
||||
* the pointer rests on a card, the camera on a click).
|
||||
* phone: the day in focus, how far a place is from that day's last stop, its
|
||||
* likely category, whether a place can be added, and the map's part (the pin
|
||||
* while the pointer rests on a card, the camera on a click).
|
||||
*
|
||||
* Suggesting writes nothing: the server holds the place as a change card to
|
||||
* decide like any other. It waits while another change is undecided, because
|
||||
* one decision answers all open cards.
|
||||
* Adding opens the planner's own place form, filled in with the place and on
|
||||
* the day in focus: the traveller sees everything about it and saves it as
|
||||
* any other place, so nothing waits for the chat's open decisions.
|
||||
*/
|
||||
export function useAssistantPlaceStrip() {
|
||||
const { t, locale } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const { tripId, onRevealMap } = useAssistantView();
|
||||
const scope = useAssistantScope();
|
||||
const { offline } = useNetworkMode();
|
||||
const can = useCanDo();
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
|
||||
const { trip, days, assignments, categories } = useTripStore(
|
||||
@@ -81,44 +58,15 @@ export function useAssistantPlaceStrip() {
|
||||
};
|
||||
})
|
||||
);
|
||||
const { focusDayId, isRunning, threadId, waiting } = useAssistantStore(
|
||||
useShallow((s) => {
|
||||
const chat = chatOf(s, tripId);
|
||||
return {
|
||||
focusDayId: chat.focus?.dayId ?? null,
|
||||
isRunning: chat.isRunning,
|
||||
threadId: chat.threadId,
|
||||
waiting: hasUndecided(chat.messages),
|
||||
};
|
||||
})
|
||||
);
|
||||
const addFoundPlace = useAssistantStore((s) => s.addFoundPlace);
|
||||
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
|
||||
const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft);
|
||||
const point = useCardPoint();
|
||||
const [adding, setAdding] = useState<string | null>(null);
|
||||
|
||||
const stripDays = useMemo(
|
||||
(): StripDay[] =>
|
||||
(days ?? []).map((day, index) => ({
|
||||
id: day.id,
|
||||
n: index + 1,
|
||||
label: dayLabel(day, index, t, locale),
|
||||
weekday: weekdayOf(day.date, locale),
|
||||
})),
|
||||
[days, t, locale]
|
||||
);
|
||||
const focusDay = stripDays.find((d) => d.id === focusDayId) ?? null;
|
||||
const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
|
||||
const from = focusDay && assignments ? lastSpot(assignments[String(focusDay.id)]) : null;
|
||||
|
||||
/**
|
||||
* Whether "+ Day" shows at all: a trip's chat (a journey's has no days), the
|
||||
* planner holds the trip and the user may add to it.
|
||||
*/
|
||||
const canSuggest = scope.kind === 'trip' && !!trip && can('place_edit', trip) && stripDays.length > 0;
|
||||
/** Why "+ Day" is off right now, as a tooltip; null when it is on. */
|
||||
let blockedKey: string | null = null;
|
||||
if (offline) blockedKey = 'assistant.error.offline';
|
||||
else if (waiting) blockedKey = 'assistant.composer.decideFirst';
|
||||
const disabled = blockedKey !== null || isRunning || threadId === null || adding !== null;
|
||||
/** Whether "Add to trip" shows at all: a trip's chat (a journey's has no days), the planner holds the trip and the user may add to it. */
|
||||
const canAdd = scope.kind === 'trip' && !!trip && can('place_edit', trip);
|
||||
|
||||
const categoryOf = (place: AssistantFoundPlace): StripCategory | null => {
|
||||
const id = categoryIdForKind(place.kind, categories);
|
||||
@@ -130,14 +78,11 @@ export function useAssistantPlaceStrip() {
|
||||
const distanceOf = (place: AssistantFoundPlace): string | null =>
|
||||
from ? formatDistance(haversineKm(from, place), unit) : null;
|
||||
|
||||
const suggest = async (place: AssistantFoundPlace, dayId: number) => {
|
||||
if (disabled) return;
|
||||
setAdding(place.ref);
|
||||
try {
|
||||
await addFoundPlace(tripId, place.ref, dayId);
|
||||
} finally {
|
||||
setAdding(null);
|
||||
}
|
||||
/** Opens the place form with it; on the phone the chat steps aside first. */
|
||||
const open = (place: AssistantFoundPlace) => {
|
||||
if (!canAdd || !trip) return;
|
||||
onRevealMap?.();
|
||||
openPlaceDraft(trip.id, place, focusDay?.id ?? null);
|
||||
};
|
||||
|
||||
/** Shows the place on the map; on the phone the chat steps aside first. */
|
||||
@@ -151,17 +96,5 @@ export function useAssistantPlaceStrip() {
|
||||
[hoverPoint]
|
||||
);
|
||||
|
||||
return {
|
||||
days: stripDays,
|
||||
focusDay,
|
||||
canSuggest,
|
||||
disabled,
|
||||
blockedKey,
|
||||
adding,
|
||||
categoryOf,
|
||||
distanceOf,
|
||||
suggest,
|
||||
show,
|
||||
hover,
|
||||
};
|
||||
return { canAdd, categoryOf, distanceOf, open, show, hover };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useJourneyStore } from '../../store/journeyStore';
|
||||
import { useCanDo } from '../../store/permissionsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { localIsoDate } from '../../utils/localDate';
|
||||
import { suggestionsFor, tripPhase } from './assistantModel';
|
||||
import { HOME_SUGGESTIONS, suggestionsFor, tripPhase } from './assistantModel';
|
||||
import { useAssistantFocus } from './useAssistantFocus';
|
||||
|
||||
export interface ReadySuggestion {
|
||||
@@ -21,7 +21,9 @@ export interface ReadySuggestion {
|
||||
* (one picked from the dashboard) neither its dates nor its rights are at
|
||||
* hand, so it gets the general questions and none that would change it. A
|
||||
* journey's chat in TREK Studio gets none, since they all ask about a trip,
|
||||
* and its rights are the journey's role, as the Studio itself reads it.
|
||||
* and its rights are the journey's role, as the Studio itself reads it. The
|
||||
* home chat outside any one trip gets its own questions across the user's
|
||||
* trips; what it may change the server decides, so nothing is held back here.
|
||||
*/
|
||||
export function useAssistantSuggestions(tripId: number): { suggestions: ReadySuggestion[]; canEdit: boolean } {
|
||||
const { t } = useTranslation();
|
||||
@@ -35,9 +37,11 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
|
||||
const journeyRole = useJourneyStore((s) =>
|
||||
scope.kind === 'journey' && s.current?.id === scope.journeyId ? s.current.my_role : undefined
|
||||
);
|
||||
const canEdit = tripChat ? !!trip && can('place_edit', trip) : (journeyRole ?? 'owner') !== 'viewer';
|
||||
const home = scope.kind === 'home';
|
||||
const canEdit = tripChat ? !!trip && can('place_edit', trip) : home || (journeyRole ?? 'owner') !== 'viewer';
|
||||
|
||||
const suggestions = useMemo((): ReadySuggestion[] => {
|
||||
if (home) return HOME_SUGGESTIONS.map((s) => ({ id: s.id, prompt: t(s.labelKey) }));
|
||||
if (!tripChat) return [];
|
||||
const phase = trip
|
||||
? tripPhase({
|
||||
@@ -53,7 +57,7 @@ export function useAssistantSuggestions(tripId: number): { suggestions: ReadySug
|
||||
place: kind === 'place' ? label : null,
|
||||
day: kind === 'day' ? label : null,
|
||||
}).map((s) => ({ id: s.id, prompt: t(s.labelKey, s.params) }));
|
||||
}, [tripChat, trip, hasPlaces, canEdit, kind, label, t]);
|
||||
}, [home, tripChat, trip, hasPlaces, canEdit, kind, label, t]);
|
||||
|
||||
return { suggestions, canEdit };
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function placeEditForm(
|
||||
}
|
||||
|
||||
/** A position the form opens on: a POI tapped on the map, or a right-click on it. */
|
||||
export interface PlacePrefill {
|
||||
export type PlacePrefill = {
|
||||
lat: number
|
||||
lng: number
|
||||
name?: string
|
||||
@@ -136,6 +136,8 @@ export interface PlacePrefill {
|
||||
website?: string
|
||||
phone?: string
|
||||
osm_id?: string
|
||||
/** A place Google knows, such as one the assistant found: the details column reads it by this id. */
|
||||
google_place_id?: string
|
||||
}
|
||||
|
||||
/** A blank form at a prefilled position, with what the POI already knew. */
|
||||
@@ -149,6 +151,7 @@ export function prefillForm(prefill: PlacePrefill, extra: Partial<PlaceFormData>
|
||||
website: prefill.website || '',
|
||||
phone: prefill.phone || '',
|
||||
osm_id: prefill.osm_id,
|
||||
google_place_id: prefill.google_place_id,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
@@ -170,7 +173,7 @@ export function openingDetailsSelection(
|
||||
}
|
||||
}
|
||||
if (prefill) {
|
||||
return { placeId: prefill.osm_id || undefined, lat: prefill.lat, lng: prefill.lng, name: prefill.name || '' }
|
||||
return { placeId: prefill.google_place_id || prefill.osm_id || undefined, lat: prefill.lat, lng: prefill.lng, name: prefill.name || '' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { weekFromPeriods } from './placeHours'
|
||||
import type { PlaceOpeningHours } from '@trek/shared'
|
||||
import {
|
||||
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
|
||||
prefillForm, type PlaceFormData,
|
||||
prefillForm, type PlaceFormData, type PlacePrefill,
|
||||
} from './PlaceFormModal.helpers'
|
||||
import { useNewCategory, usePlaceForm } from './usePlaceForm'
|
||||
import { usePlaceSearch } from './usePlaceSearch'
|
||||
@@ -64,7 +64,7 @@ interface PlaceFormModalProps {
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
|
||||
place: Place | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null
|
||||
prefillCoords?: (PlacePrefill & { stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string }) | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
||||
|
||||
@@ -22,6 +22,8 @@ import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import StudioAssistantPanel, { StudioAssistantColumn, StudioAssistantToggle } from './StudioAssistantPanel';
|
||||
import type { StudioAssistant } from './useStudioAssistant';
|
||||
|
||||
/** The assistant's surface is loaded lazily, which can outlast findBy's default second on CI. */
|
||||
const LAZY = { timeout: 10_000 };
|
||||
const JOURNEY = 12;
|
||||
const KEY = assistantChatKey({ kind: 'journey', journeyId: JOURNEY });
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
@@ -75,7 +77,8 @@ describe('StudioAssistantPanel', () => {
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
expect(screen.getByRole('complementary', { name: 'Book assistant' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
const notice = screen.getByRole('note');
|
||||
expect(notice.textContent).toContain('Only that page goes to Anthropic');
|
||||
|
||||
@@ -97,7 +100,8 @@ describe('StudioAssistantPanel', () => {
|
||||
|
||||
useAssistantStore.setState({ status: { ...STATUS, studioImagesAcked: true } });
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -144,7 +148,8 @@ describe('StudioAssistantPanel', () => {
|
||||
it('FE-STUDIO-ASTPANEL-005: the journey chat offers no trip notes, which only a trip turn reads', async () => {
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Recent chats' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Trip notes' })).toBeNull();
|
||||
});
|
||||
@@ -175,7 +180,8 @@ describe('StudioAssistantPanel', () => {
|
||||
});
|
||||
render(<StudioAssistantPanel assistant={assistant()} title="Lisbon" />);
|
||||
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Show it pages' })).toBeNull();
|
||||
});
|
||||
@@ -207,7 +213,8 @@ describe('StudioAssistantPanel', () => {
|
||||
|
||||
render(<StudioAssistantColumn assistant={assistant()} />);
|
||||
expect(screen.getByRole('complementary', { name: 'Book assistant' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('What should the book look like?')).toBeInTheDocument();
|
||||
// The chat is a lazy chunk; on a busy runner it can take longer than findBy's default second.
|
||||
expect(await screen.findByText('What should the book look like?', undefined, LAZY)).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Journey' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-007
|
||||
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-008
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -22,6 +22,8 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import MBottomNav from './MBottomNav';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||
@@ -97,4 +99,15 @@ describe('MBottomNav', () => {
|
||||
render(<MBottomNav />, { initialEntries: ['/vacay'] });
|
||||
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOTTOMNAV-008: the More list leads with the assistant while it can run, and opens the home chat', async () => {
|
||||
const user = userEvent.setup();
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } });
|
||||
render(<MBottomNav />, { initialEntries: ['/atlas'] });
|
||||
await user.click(screen.getByRole('button', { name: 'More' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { ChevronRight, MoreHorizontal, Plus, Search, Upload } from 'lucide-react'
|
||||
import { ChevronRight, MoreHorizontal, Plus, Search, Sparkles, Upload } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { normalizeAppearance } from '@trek/shared'
|
||||
import { useNavItems, splitMobileNav } from '../../components/Layout/navItems'
|
||||
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction'
|
||||
import MFab from './MFab'
|
||||
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome'
|
||||
|
||||
interface NavItem { to: string; label: string; icon: LucideIcon }
|
||||
|
||||
@@ -26,6 +27,8 @@ export default function MBottomNav() {
|
||||
// the dock yields the slot instead (see screenFabSlot below, #1811).
|
||||
const create = useCreateAction(true)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
// The home chat leads the More list on every page outside a trip, as the assistant tile leads a trip's.
|
||||
const assistant = useAssistantHomeLauncher()
|
||||
|
||||
// Close the popover when the route changes underneath it (browser back etc.).
|
||||
useEffect(() => setMoreOpen(false), [location.pathname])
|
||||
@@ -42,6 +45,11 @@ export default function MBottomNav() {
|
||||
|
||||
const isActive = (to: string) => isNavItemActive(location.pathname, to)
|
||||
const moreActive = moreItems.some(item => isActive(item.to))
|
||||
const moreRows = [
|
||||
...(assistant.available ? [{ key: 'assistant', label: t('assistant.launcher.tooltip'), icon: Sparkles, go: assistant.open }] : []),
|
||||
...moreItems.map(({ to, label, icon }) => ({ key: to, label, icon, go: () => navigate(to) })),
|
||||
]
|
||||
const hasMore = moreRows.length > 0
|
||||
|
||||
// The FAB gives way to a decorative logo slot on screens without an add
|
||||
// action (settings/admin, demo Z. 1372/1429).
|
||||
@@ -55,7 +63,7 @@ export default function MBottomNav() {
|
||||
|
||||
// Split so the raised centre slot sits dead centre; the More slot always
|
||||
// closes the right group.
|
||||
const slotCount = dockItems.length + (moreItems.length > 0 ? 1 : 0)
|
||||
const slotCount = dockItems.length + (hasMore ? 1 : 0)
|
||||
const splitAt = Math.ceil(slotCount / 2)
|
||||
const left = dockItems.slice(0, splitAt)
|
||||
const right = dockItems.slice(splitAt)
|
||||
@@ -94,11 +102,11 @@ export default function MBottomNav() {
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{moreItems.map(({ to, label, icon: Icon }) => (
|
||||
{moreRows.map(({ key, label, icon: Icon, go }) => (
|
||||
<button
|
||||
key={to}
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => { setMoreOpen(false); navigate(to) }}
|
||||
onClick={() => { setMoreOpen(false); go() }}
|
||||
className="flex items-center gap-[13px] rounded-[18px] bg-[color:var(--m-ic)] px-4 py-[14px] text-start"
|
||||
>
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] text-m-ink">
|
||||
@@ -136,7 +144,7 @@ export default function MBottomNav() {
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-around">
|
||||
{right.map(renderItem)}
|
||||
{moreItems.length > 0 && (
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoreOpen(v => !v)}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import type { AssistantChatTrip } from '../../../components/Assistant/assistantViewContext';
|
||||
import type { EntityTarget } from '../../../components/Assistant/cards/cardTypes';
|
||||
import { useAssistantHome } from '../../../components/Assistant/useAssistantHome';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScreen';
|
||||
|
||||
/**
|
||||
* The assistant on the phone outside a trip: the home chat, full screen over
|
||||
* the dashboard, Vacay, Atlas, the collections or the settings, opened from
|
||||
* the dashboard's head or the dock's More list. It knows all of the user's
|
||||
* trips; going on in one trip's own chat opens that trip with its chat on
|
||||
* screen and the question waiting in its composer. Another page a card points
|
||||
* at closes the chat and opens there.
|
||||
*
|
||||
* Inside a trip the trip's own screen (MAssistantScreen) shows its chat, so
|
||||
* this one stays away there. Nothing shows until the assistant has a
|
||||
* connection this user can use.
|
||||
*/
|
||||
export default function MAssistantHomeScreen() {
|
||||
const navigate = useNavigate();
|
||||
const inTrip = useMatch('/trips/:id/*') !== null;
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open);
|
||||
const minimized = useAssistantStore((s) => s.window.minimized);
|
||||
const loadStatus = useAssistantStore((s) => s.loadStatus);
|
||||
const close = useAssistantStore((s) => s.close);
|
||||
|
||||
// The trip's screen takes over with the window still open, so its chat comes up there.
|
||||
const switchTo = useCallback((trip: AssistantChatTrip) => void navigate(`/trips/${trip.id}`), [navigate]);
|
||||
const home = useAssistantHome(switchTo);
|
||||
const hops = useMemo(
|
||||
(): MapHops => ({
|
||||
open: (target: EntityTarget) => {
|
||||
if (target.kind !== 'page') return;
|
||||
close();
|
||||
void navigate(target.path);
|
||||
},
|
||||
}),
|
||||
[close, navigate]
|
||||
);
|
||||
|
||||
// The desktop window loads this itself; on the phone the pages outside a trip ask once.
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
if (inTrip || !available || !open || minimized) return null;
|
||||
return (
|
||||
<AssistantScreen
|
||||
tripId={home.chat.id}
|
||||
tripTitle={home.chat.title}
|
||||
hidden={false}
|
||||
hops={hops}
|
||||
onOpenTripChat={home.openTripChat}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
|
||||
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Trash2, Users,
|
||||
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Sparkles, Trash2, Users,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import MDancingTrek from '../../components/MDancingTrek'
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useAuthStore } from '../../../store/authStore'
|
||||
import { useInAppNotificationStore } from '../../../store/inAppNotificationStore'
|
||||
import { useDashboardPlugins } from '../../../components/Dashboard/useDashboardPlugins'
|
||||
import { useAssistantHomeLauncher } from '../../../components/Assistant/useAssistantHome'
|
||||
import type { TripCardBadge } from '../../../api/client'
|
||||
import DemoBanner from '../../../components/Layout/DemoBanner'
|
||||
import { IcsSubscribeModal } from '../../../components/Planner/IcsSubscribeModal'
|
||||
@@ -55,6 +56,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
const unread = useInAppNotificationStore(s => s.unreadCount)
|
||||
const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const { available: assistantAvailable, open: openAssistant } = useAssistantHomeLauncher()
|
||||
const [subOpen, setSubOpen] = useState(false)
|
||||
|
||||
// Plugin dashboard widgets + trip-card badges, mirroring the desktop page:
|
||||
@@ -219,6 +221,11 @@ export default function MDashboard(): React.ReactElement {
|
||||
</span>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1" />
|
||||
{assistantAvailable && (
|
||||
<MIconBtn ariaLabel={t('assistant.launcher.tooltip')} onClick={openAssistant}>
|
||||
<Sparkles size={18} strokeWidth={2} />
|
||||
</MIconBtn>
|
||||
)}
|
||||
<MIconBtn ariaLabel={t('notifications.title')} onClick={() => navigate('/notifications')}>
|
||||
<Bell size={18} strokeWidth={2} />
|
||||
{unread > 0 && (
|
||||
|
||||
@@ -17,6 +17,7 @@ import AssistantCompareSwitch from '../../../../components/Assistant/AssistantCo
|
||||
import AssistantMemoryDialog from '../../../../components/Assistant/AssistantMemoryDialog';
|
||||
import AssistantSurfaceLazy from '../../../../components/Assistant/AssistantSurfaceLazy';
|
||||
import { AI_SETTINGS_PATH } from '../../../../components/Assistant/assistantParts';
|
||||
import type { AssistantChatTrip } from '../../../../components/Assistant/assistantViewContext';
|
||||
import type { EntityTarget } from '../../../../components/Assistant/cards/cardTypes';
|
||||
import RoutePeekLazy from '../../../../components/Assistant/map/RoutePeekLazy';
|
||||
import { moveRoutePeek } from '../../../../components/Assistant/map/routePeekControl';
|
||||
@@ -26,6 +27,7 @@ import { useAssistantThread } from '../../../../components/Assistant/useAssistan
|
||||
import { useTripMemory } from '../../../../components/Assistant/useTripMemory';
|
||||
import { useKeyboardInset } from '../../../../hooks/useKeyboardInset';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { assistantScopeOf } from '../../../../store/assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import MDropdownPanel from '../../../components/MDropdownPanel';
|
||||
import MIconBtn from '../../../components/MIconBtn';
|
||||
@@ -72,14 +74,17 @@ function useBackCloses(open: boolean, onClose: () => void): void {
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
/** The ways the chat hands the screen to the map, a tab of the trip or another page. */
|
||||
interface MapHops {
|
||||
/**
|
||||
* The ways the chat hands the screen to the map, a tab of the trip or another
|
||||
* page. Outside a trip (the home chat) only another page can be opened.
|
||||
*/
|
||||
export interface MapHops {
|
||||
/** What a card or an applied change points at: selected on the map, a tab opened, a page navigated to. */
|
||||
open: (target: EntityTarget) => void;
|
||||
/** A day picked in the chat (its weather): the day is selected. */
|
||||
focusDay: (dayId: number) => void;
|
||||
focusDay?: (dayId: number) => void;
|
||||
/** Before the map moves to something the chat points at, such as a found place. */
|
||||
revealMap: () => void;
|
||||
revealMap?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +95,7 @@ interface MapHops {
|
||||
* the chat closes with it. Read through a ref, so the chat does not re-render
|
||||
* with every render of the trip screen.
|
||||
*/
|
||||
function useMapHops({ planner, shell }: MTripSheetsProps): MapHops {
|
||||
function useMapHops({ planner, shell }: MTripSheetsProps): Required<MapHops> {
|
||||
const navigate = useNavigate();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const setMinimized = useAssistantStore((s) => s.setMinimized);
|
||||
@@ -147,13 +152,21 @@ function useMapHops({ planner, shell }: MTripSheetsProps): MapHops {
|
||||
}
|
||||
|
||||
interface ScreenProps {
|
||||
/** The chat's key: a trip's id, or the home chat's. */
|
||||
tripId: number;
|
||||
tripTitle: string;
|
||||
hidden: boolean;
|
||||
hops: MapHops;
|
||||
/** The home chat's way into one trip's own chat, from the card that offers it. */
|
||||
onOpenTripChat?: (trip: AssistantChatTrip, question: string | null) => void;
|
||||
}
|
||||
|
||||
function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
|
||||
/**
|
||||
* The chat screen itself, the same over a trip and (the home chat) on the
|
||||
* pages outside one: the head with the history and the menu, and the chat.
|
||||
* The trip notes are a trip's, so the home chat's menu goes without them.
|
||||
*/
|
||||
export function AssistantScreen({ tripId, tripTitle, hidden, hops, onOpenTripChat }: ScreenProps) {
|
||||
const { t, locale } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const thread = useAssistantThread(tripId);
|
||||
@@ -163,6 +176,7 @@ function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const memory = useTripMemory(tripId);
|
||||
const tripChat = assistantScopeOf(tripId).kind === 'trip';
|
||||
const hints = useAssistantHintsSetting();
|
||||
useBackCloses(!hidden, close);
|
||||
|
||||
@@ -247,11 +261,13 @@ function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
|
||||
onClose={() => setPanel(null)}
|
||||
className="end-4 top-[calc(var(--m-safe-top,12px)+48px)]"
|
||||
>
|
||||
<MListRow
|
||||
icon={NotebookPen}
|
||||
label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}
|
||||
onClick={() => pick(() => setMemoryOpen(true))}
|
||||
/>
|
||||
{tripChat && (
|
||||
<MListRow
|
||||
icon={NotebookPen}
|
||||
label={t(memory.has ? 'assistant.memory.edit' : 'assistant.memory.title')}
|
||||
onClick={() => pick(() => setMemoryOpen(true))}
|
||||
/>
|
||||
)}
|
||||
<MListRow
|
||||
icon={hints.on ? LightbulbOff : Lightbulb}
|
||||
label={t(hints.on ? 'assistant.hint.menuOff' : 'assistant.hint.menuOn')}
|
||||
@@ -286,6 +302,7 @@ function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
|
||||
onOpen={hops.open}
|
||||
onFocusDay={hops.focusDay}
|
||||
onRevealMap={hops.revealMap}
|
||||
onOpenTripChat={onOpenTripChat}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -625,7 +625,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<TourPlannerMapStage
|
||||
tripId={tripId} mapTileUrl={mapTileUrl} leftHidden={leftHidden} rightHidden={rightHidden} t={t}
|
||||
tourPlanner={tourPlanner} tourMap={tourMap} locationSearch={locationSearch} canPlaceEdit={canPlaceEdit}
|
||||
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx}
|
||||
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx} assistantRoutes={assistantMap}
|
||||
onViewportChange={wandererBrowser.onViewportChange} paddleChecklist={pageState.paddleChecklist}
|
||||
/>
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressG
|
||||
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
|
||||
import { MapUndoHint } from '../../components/Map/MapUndoHint';
|
||||
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
|
||||
import type { AssistantMapProps } from '../../components/Map/mapViewContract';
|
||||
import LocationSelect from '../../components/Planner/LocationSelect';
|
||||
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
|
||||
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
|
||||
@@ -74,6 +75,11 @@ export interface TourPlannerMapStageProps
|
||||
onViewportChange?: (bounds: FerrataBbox) => void;
|
||||
/** The open paddle tour's checklist (PAD-06): its part for the picked object rides in the object's map card. */
|
||||
paddleChecklist?: PageState['paddleChecklist'];
|
||||
/** The routes the assistant's cards show and its camera, as on the plan's map, so a proposed hike shows here too. */
|
||||
assistantRoutes?: Pick<
|
||||
AssistantMapProps,
|
||||
'assistantCamera' | 'assistantRouteLines' | 'onAssistantRouteHover' | 'onAssistantRoutePoint'
|
||||
>;
|
||||
}
|
||||
|
||||
/** What the map reads of the tour on screen: a GPX file viewed as it came in has its analysis and no points. */
|
||||
@@ -104,6 +110,7 @@ export function TourPlannerMapStage({
|
||||
rightPanelPx,
|
||||
onViewportChange,
|
||||
paddleChecklist,
|
||||
assistantRoutes,
|
||||
}: Readonly<TourPlannerMapStageProps>): ReactElement {
|
||||
const [terrain3d, setTerrain3d] = useState(false);
|
||||
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
|
||||
@@ -285,6 +292,7 @@ export function TourPlannerMapStage({
|
||||
onViewportChange={viewportChanged}
|
||||
placeTool={placeTool}
|
||||
{...poiMarkers}
|
||||
{...assistantRoutes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import type { PlaceFormOpeners } from './usePlaceFormOpeners';
|
||||
|
||||
/**
|
||||
* A place the assistant found, opened in the place form when its card is
|
||||
* clicked: filled in with what the search knew, the details column reading
|
||||
* the rest (photos, hours, description) by its Google id. The form is the
|
||||
* planner's own, so the place is saved as any other, on the day in focus.
|
||||
*/
|
||||
export function useAssistantPlaceDraft(tripId: number, openAddPlaceFromPoi: PlaceFormOpeners['openAddPlaceFromPoi']) {
|
||||
const draft = useAssistantStore((s) => (s.placeDraft?.tripId === tripId ? s.placeDraft : null));
|
||||
const takePlaceDraft = useAssistantStore((s) => s.takePlaceDraft);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) return;
|
||||
takePlaceDraft(draft.seq);
|
||||
const { place } = draft;
|
||||
openAddPlaceFromPoi(
|
||||
{
|
||||
lat: place.lat,
|
||||
lng: place.lng,
|
||||
name: place.name,
|
||||
address: place.address,
|
||||
website: null,
|
||||
phone: null,
|
||||
osm_id: place.osmId ?? '',
|
||||
google_place_id: place.googlePlaceId,
|
||||
poi_type: place.kind,
|
||||
},
|
||||
draft.dayId
|
||||
);
|
||||
}, [draft, openAddPlaceFromPoi, takePlaceDraft]);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function usePlaceFormOpeners(options: PlaceFormOpenersOptions) {
|
||||
// Open the Add-Place form pre-filled from an OSM "explore" POI marker: all the
|
||||
// data already comes from the POI, so no reverse-geocode is needed.
|
||||
const openAddPlaceFromPoi = useCallback((
|
||||
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string; category?: string | null; poi_type?: string | null },
|
||||
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string; google_place_id?: string | null; category?: string | null; poi_type?: string | null },
|
||||
dayId?: number | null,
|
||||
/** Index within that day. Omitted, the place is appended, which is what every caller did before. */
|
||||
position?: number | null,
|
||||
@@ -100,6 +100,7 @@ export function usePlaceFormOpeners(options: PlaceFormOpenersOptions) {
|
||||
// A plugin POI's `plugin:<pluginId>:<id>` rides along as it is. The server never
|
||||
// takes that prefix for a Google place id, so the details column makes no Google call.
|
||||
osm_id: poi.osm_id,
|
||||
google_place_id: poi.google_place_id || undefined,
|
||||
// What the map search filed it under, so the form can preselect a category (#2282).
|
||||
category: poi.poi_type || poi.category || undefined,
|
||||
stop_type: stop?.stopType ?? null,
|
||||
|
||||
@@ -27,7 +27,7 @@ export function usePlannerDialogs(options: PlannerDialogsOptions) {
|
||||
const { tripId, tripActions, searchParams, setSearchParams } = options
|
||||
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
|
||||
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
|
||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null>(null)
|
||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; google_place_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null>(null)
|
||||
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
|
||||
// Day context of the open form. Set only by the day-scoped entry points (the
|
||||
// mobile day toolbar, a long-press on the mobile map); every other opener
|
||||
|
||||
@@ -52,6 +52,7 @@ import { usePlannerDialogs } from './usePlannerDialogs'
|
||||
import { usePlaceDeepLink } from './usePlaceDeepLink'
|
||||
import { useAssistantDeepLink } from './useAssistantDeepLink'
|
||||
import { useAssistantCompareView, useAssistantMapLink } from './useAssistantMapLink'
|
||||
import { useAssistantPlaceDraft } from './useAssistantPlaceDraft'
|
||||
import { usePlannerMapView } from './usePlannerMapView'
|
||||
import { useMapPlaces } from './useMapPlaces'
|
||||
import { useRoadtripFeed } from './useRoadtripFeed'
|
||||
@@ -593,6 +594,7 @@ export function useTripPlanner() {
|
||||
setPrefillCoords, setEditingPlace, setEditingAssignmentId, setPlaceFormDayId, setPlaceFormPosition, setShowPlaceForm,
|
||||
roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripCorridor, manualStopTargetFor, isTourPlace,
|
||||
})
|
||||
useAssistantPlaceDraft(tripId, openAddPlaceFromPoi)
|
||||
|
||||
const {
|
||||
handleReorder, handleReorderDays, handleUpdateDayTitle, handleMoveToDay, handleRemoveAssignment,
|
||||
|
||||
@@ -79,7 +79,12 @@ describe('aiConnectionsRepo', () => {
|
||||
});
|
||||
|
||||
it('FE-AI-CONN-REPO-004: reads and writes the admin settings and the instance connection', async () => {
|
||||
const settings = { policy: 'user' as const, dailyTurns: 50, retention: { mode: 'forever' as const } };
|
||||
const settings = {
|
||||
policy: 'user' as const,
|
||||
dailyTurns: 50,
|
||||
webSearch: true,
|
||||
retention: { mode: 'forever' as const },
|
||||
};
|
||||
const connection: AiAdminConnectionResponse = {
|
||||
connection: {
|
||||
...VIEW,
|
||||
@@ -102,6 +107,7 @@ describe('aiConnectionsRepo', () => {
|
||||
await expect(aiConnectionsRepo.saveAdminSettings({ ...settings, dailyTurns: 20 })).resolves.toEqual({
|
||||
...settings,
|
||||
dailyTurns: 20,
|
||||
webSearch: true,
|
||||
});
|
||||
await expect(aiConnectionsRepo.adminConnection()).resolves.toEqual(connection);
|
||||
await expect(aiConnectionsRepo.adminModels()).resolves.toEqual([
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
import type { AssistantScope } from '@trek/shared';
|
||||
import { type AssistantScope, HOME_ASSISTANT_SCOPE } from '@trek/shared';
|
||||
|
||||
import type { AssistantTarget } from '../api/assistant';
|
||||
|
||||
/**
|
||||
* The key the assistant store keeps a conversation under, and the one every
|
||||
* part of the chat passes back to it: a trip's id for the planner's chat, and
|
||||
* the journey's id negated for the chat in TREK Studio. Trip ids are positive
|
||||
* (a trip is never made offline with a temporary id), so the two never meet,
|
||||
* and the parts that only make sense on a trip (the map, the day list, found
|
||||
* places) find no trip under a journey's key and stay empty.
|
||||
* part of the chat passes back to it: a trip's id for the planner's chat, the
|
||||
* journey's id negated for the chat in TREK Studio, and 0 for the home chat
|
||||
* outside any one trip (the dashboard, Vacay, Atlas, collections, settings).
|
||||
* Trip ids are positive (a trip is never made offline with a temporary id), so
|
||||
* the three never meet, and the parts that only make sense on a trip (the map,
|
||||
* the day list, found places) find no trip under a journey's key or the home
|
||||
* key and stay empty.
|
||||
*
|
||||
* A number rather than `assistantScopeKey`'s `journey:12`, so the trip chat
|
||||
* keeps the key it always had and every part that reads it stays as it is.
|
||||
*/
|
||||
export type AssistantChatKey = number;
|
||||
|
||||
/** The key of the home chat. */
|
||||
export const HOME_CHAT_KEY: AssistantChatKey = 0;
|
||||
|
||||
/** The key a scope's chat is kept under. */
|
||||
export function assistantChatKey(scope: AssistantScope): AssistantChatKey {
|
||||
if (scope.kind === 'home') return HOME_CHAT_KEY;
|
||||
return scope.kind === 'trip' ? scope.tripId : -scope.journeyId;
|
||||
}
|
||||
|
||||
/** What the chat under a key is about. */
|
||||
export function assistantScopeOf(key: AssistantChatKey): AssistantScope {
|
||||
if (key === HOME_CHAT_KEY) return HOME_ASSISTANT_SCOPE;
|
||||
return key < 0 ? { kind: 'journey', journeyId: -key } : { kind: 'trip', tripId: key };
|
||||
}
|
||||
|
||||
/** What the repo is called with for the chat under a key: a trip's id as it always was, a journey's scope. */
|
||||
/** What the repo is called with for the chat under a key: a trip's id as it always was, any other scope as it is. */
|
||||
export function assistantTargetOf(key: AssistantChatKey): AssistantTarget {
|
||||
return key < 0 ? assistantScopeOf(key) : key;
|
||||
return key > 0 ? key : assistantScopeOf(key);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ export function applyChunk(
|
||||
]);
|
||||
case 'data-trek-weather':
|
||||
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-weather', data: chunk.data }]);
|
||||
case 'data-trek-sources':
|
||||
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-sources', data: chunk.data }]);
|
||||
case 'data-trek-follow-ups':
|
||||
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-follow-ups', data: chunk.data }]);
|
||||
case 'data-trek-card':
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-ROUTE-001 to FE-AI-ROUTE-005
|
||||
* FE-AI-ROUTE-001 to FE-AI-ROUTE-006
|
||||
*
|
||||
* Which routes of the chat's cards the plan's map draws, and how: changes
|
||||
* that still stand on the map with the way they replace, the cards of the
|
||||
@@ -127,6 +127,20 @@ describe('assistantRouteModel', () => {
|
||||
expect(refs[1]?.card).toBe(SAVED);
|
||||
});
|
||||
|
||||
it('FE-AI-ROUTE-006: once one of the ways is taken into the trip, the answer draws none of them; a discarded take brings them back', () => {
|
||||
const latest = message([...read('call_1', HIKE), ...read('call_2', GENTLE)], 'msg-1');
|
||||
const take = (approval: Approval) => {
|
||||
const parts = held('t1', PENDING, approval);
|
||||
const change = parts[1] as Extract<AssistantMessagePart, { type: 'data-trek-change' }>;
|
||||
change.data = { ...change.data, takenFrom: 'call_2' };
|
||||
return message(parts, 'msg-2');
|
||||
};
|
||||
const waiting = routeRefs([latest, take({})], {}, NOBODY, LOOK, AS_WAYS);
|
||||
expect(waiting.map((ref) => `${ref.toolCallId}:${ref.use}`)).toEqual(['t1:before', 't1:pending']);
|
||||
const discarded = routeRefs([latest, take({ approved: false })], {}, NOBODY, LOOK, AS_WAYS);
|
||||
expect(discarded.map((ref) => ref.toolCallId)).toEqual(['call_1', 'call_2']);
|
||||
});
|
||||
|
||||
it("FE-AI-ROUTE-003: lines lie in order (replaced way, others, the chosen way, the lit one) in their ways' colours", () => {
|
||||
const previous = { line: flat(HIKE), distanceM: 8000, ascentM: null, minutes: null };
|
||||
const messages = [
|
||||
|
||||
@@ -101,7 +101,8 @@ function standsOnMap(part: ToolCallPart, receipt: AssistantApplied | undefined,
|
||||
/**
|
||||
* The cards of the latest answer that draws any, laid out as the chat lays
|
||||
* them out: a group of several is a choice of ways, each with its letter, the
|
||||
* chosen one (by `picks`, else the first) picked. Older answers drop.
|
||||
* chosen one (by `picks`, else the first) picked. Older answers drop, and so
|
||||
* do the ways of a group once one of them was taken into the trip.
|
||||
*/
|
||||
function lookRefs(
|
||||
messages: readonly AssistantMessage[],
|
||||
@@ -111,6 +112,7 @@ function lookRefs(
|
||||
layout: AnswerLayout
|
||||
): RouteRef[] {
|
||||
const chosenIds = new Set(Object.values(picks));
|
||||
const taken = takenProposals(messages);
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const answers = (messages[index]?.parts ?? []).flatMap((part): RouteCardEntry[] =>
|
||||
part.type === 'data-trek-card' && part.data.card && !changeIds.has(part.data.toolCallId)
|
||||
@@ -121,6 +123,7 @@ function lookRefs(
|
||||
const drawn = new Set(lined.map((entry) => entry.toolCallId));
|
||||
if (drawn.size === 0) continue;
|
||||
return layout(answers).flatMap((group) => {
|
||||
if (group.some((entry) => taken.has(entry.toolCallId))) return [];
|
||||
const ways = group.length >= 2;
|
||||
const chosen = group.find((entry) => chosenIds.has(entry.toolCallId)) ?? group[0];
|
||||
return group.flatMap((entry, position): RouteRef[] => {
|
||||
@@ -133,6 +136,28 @@ function lookRefs(
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The proposals taken into the trip ("Plan this") and not discarded since:
|
||||
* each stands as its change from then on, so its answer's ways leave the map.
|
||||
*/
|
||||
function takenProposals(messages: readonly AssistantMessage[]): Set<string> {
|
||||
const dropped = new Set<string>();
|
||||
for (const message of messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool-call' || !part.approval) continue;
|
||||
if (part.approval.approved === false || part.approval.resolution) dropped.add(part.toolCallId);
|
||||
}
|
||||
}
|
||||
const taken = new Set<string>();
|
||||
for (const message of messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'data-trek-change' || !part.data.takenFrom) continue;
|
||||
if (!dropped.has(part.data.toolCallId)) taken.add(part.data.takenFrom);
|
||||
}
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every card of the chat the map draws now, in the part it plays: the
|
||||
* changes that still stand on the map (each with the way it replaces), then
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* FE-AI-STORE-HOME-001 to FE-AI-STORE-HOME-005
|
||||
*
|
||||
* The home chat (the assistant outside any one trip) in the same store as the
|
||||
* trips' and the journeys': kept under the key 0, which no trip and no
|
||||
* journey has; every call it has goes out with the home scope; an applied
|
||||
* change does not make a planner jump; the trip-only actions (files, found
|
||||
* places, proposals) do nothing for it. The repo is faked, nothing reaches
|
||||
* Dexie.
|
||||
*/
|
||||
import type { UiStreamChunk } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { assistantRepo } from '../repo/assistantRepo';
|
||||
import { assistantChatKey, assistantScopeOf, assistantTargetOf, HOME_CHAT_KEY } from './assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from './assistantStore';
|
||||
import { useSettingsStore } from './settingsStore';
|
||||
|
||||
const HOME = { kind: 'home' } as const;
|
||||
const WRITE_ID = 'c0ffee00-0000-4000-8000-000000000003';
|
||||
|
||||
const HELD_TURN: UiStreamChunk[] = [
|
||||
{ type: 'start', messageId: 'msg-1' },
|
||||
{ type: 'data-trek-thread', data: { threadId: 21, title: 'Japan' } },
|
||||
{ type: 'text-delta', id: 't', delta: 'Japan goes into your atlas.' },
|
||||
{
|
||||
type: 'tool-input-available',
|
||||
toolCallId: WRITE_ID,
|
||||
toolName: 'mark_country_visited',
|
||||
input: { country_code: 'JP' },
|
||||
},
|
||||
{ type: 'tool-approval-request', approvalId: WRITE_ID, toolCallId: WRITE_ID },
|
||||
{ type: 'finish', finishReason: 'tool-calls' },
|
||||
];
|
||||
|
||||
const APPLIED_TURN: UiStreamChunk[] = [
|
||||
{ type: 'start', messageId: 'msg-1' },
|
||||
{ type: 'tool-output-available', toolCallId: WRITE_ID, output: { country_code: 'JP' } },
|
||||
{
|
||||
type: 'data-trek-applied',
|
||||
data: { toolCallId: WRITE_ID, entity: 'atlas', ids: [], dayId: null, undoUntil: '2026-10-23T08:15:00.000Z' },
|
||||
},
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
];
|
||||
|
||||
async function* streamOf(chunks: UiStreamChunk[]): AsyncGenerator<UiStreamChunk> {
|
||||
for (const chunk of chunks) yield chunk;
|
||||
}
|
||||
|
||||
const store = () => useAssistantStore.getState();
|
||||
const chat = () => chatOf(store(), HOME_CHAT_KEY);
|
||||
|
||||
beforeEach(() => {
|
||||
useAssistantStore.setState({
|
||||
status: null,
|
||||
window: { open: false, minimized: false },
|
||||
byTrip: {},
|
||||
lastApplied: null,
|
||||
});
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: 'en' } }));
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('assistantChatKey: the home chat', () => {
|
||||
it('FE-AI-STORE-HOME-001: is kept under 0, which reads back as the home scope and is called with it', () => {
|
||||
expect(assistantChatKey(HOME)).toBe(HOME_CHAT_KEY);
|
||||
expect(HOME_CHAT_KEY).toBe(0);
|
||||
expect(assistantScopeOf(HOME_CHAT_KEY)).toEqual(HOME);
|
||||
expect(assistantTargetOf(HOME_CHAT_KEY)).toEqual(HOME);
|
||||
// A trip and a journey keep their keys.
|
||||
expect(assistantTargetOf(5)).toBe(5);
|
||||
expect(assistantTargetOf(-5)).toEqual({ kind: 'journey', journeyId: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantStore: the home chat', () => {
|
||||
it('FE-AI-STORE-HOME-002: sends with the home scope, apart from every trip', async () => {
|
||||
const send = vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(streamOf(HELD_TURN));
|
||||
|
||||
await store().send(HOME_CHAT_KEY, ' I was in Japan ');
|
||||
|
||||
const [target, body] = send.mock.calls[0] ?? [];
|
||||
expect(target).toEqual(HOME);
|
||||
expect(body).toMatchObject({ locale: 'en', threadId: null, text: 'I was in Japan' });
|
||||
expect(chat()).toMatchObject({ threadId: 21, isRunning: false, error: null });
|
||||
expect(chat().messages.map((m) => [m.role, m.status])).toEqual([
|
||||
['user', 'complete'],
|
||||
['assistant', 'awaiting_approval'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-HOME-003: resume and delete reach the home threads', async () => {
|
||||
const summary = { id: 21, title: 'Japan', lastMessageAt: '2026-10-23T08:00:00.000Z' };
|
||||
const threads = vi.spyOn(assistantRepo, 'threads').mockResolvedValueOnce([summary]);
|
||||
const open = vi.spyOn(assistantRepo, 'thread').mockResolvedValueOnce({ thread: summary, messages: [] });
|
||||
|
||||
await store().resume(HOME_CHAT_KEY);
|
||||
expect(threads).toHaveBeenCalledWith(HOME);
|
||||
expect(open).toHaveBeenCalledWith(HOME, 21);
|
||||
|
||||
const remove = vi.spyOn(assistantRepo, 'deleteThread').mockResolvedValueOnce(undefined);
|
||||
await store().deleteThread(HOME_CHAT_KEY, 21);
|
||||
expect(remove).toHaveBeenCalledWith(HOME, 21);
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-HOME-004: a decision goes to the home thread; an applied change makes no planner jump', async () => {
|
||||
vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(streamOf(HELD_TURN));
|
||||
await store().send(HOME_CHAT_KEY, 'I was in Japan');
|
||||
const decide = vi.spyOn(assistantRepo, 'decide').mockResolvedValueOnce(streamOf(APPLIED_TURN));
|
||||
|
||||
await store().decide(HOME_CHAT_KEY, [{ toolCallId: WRITE_ID, approved: true }]);
|
||||
|
||||
expect(decide).toHaveBeenCalledWith(HOME, 21, expect.anything(), expect.any(AbortSignal));
|
||||
expect(store().lastApplied).toBeNull();
|
||||
expect(chat().compare).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-HOME-005: files, found places and proposals do nothing in the home chat', async () => {
|
||||
useAssistantStore.setState({ byTrip: { [HOME_CHAT_KEY]: { ...chat(), threadId: 21 } } });
|
||||
const attach = vi.spyOn(assistantRepo, 'attach');
|
||||
const found = vi.spyOn(assistantRepo, 'addFoundPlace');
|
||||
const proposal = vi.spyOn(assistantRepo, 'takeProposal');
|
||||
|
||||
await store().attach(HOME_CHAT_KEY, new File(['x'], 'booking.pdf', { type: 'application/pdf' }));
|
||||
await store().addFoundPlace(HOME_CHAT_KEY, 's1:0', 31);
|
||||
await expect(store().takeProposal(HOME_CHAT_KEY, WRITE_ID)).resolves.toBe('skipped');
|
||||
|
||||
expect(attach).not.toHaveBeenCalled();
|
||||
expect(found).not.toHaveBeenCalled();
|
||||
expect(proposal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AssistantDecision,
|
||||
type AssistantErrorCode,
|
||||
type AssistantFocus,
|
||||
type AssistantFoundPlace,
|
||||
type AssistantMapView,
|
||||
type AssistantMention,
|
||||
type AssistantMessage,
|
||||
@@ -149,6 +150,8 @@ interface AssistantState {
|
||||
routeFocus: { tripId: number; ref: string; focus: RouteProfileFocus; source: 'card' | 'map' } | null;
|
||||
/** A line a card asks the map to draw while the pointer rests on it (a day of a drive, a saved tour). */
|
||||
routePreview: { tripId: number; ref: string; lines: LatLng[][]; color: string } | null;
|
||||
/** A found place to open in the planner's place form, filled in; `seq` tells a new one from the same place asked again. */
|
||||
placeDraft: { tripId: number; seq: number; place: AssistantFoundPlace; dayId: number | null } | null;
|
||||
/** The route card the phone shows over the map. */
|
||||
routePeek: { tripId: number; toolCallId: string } | null;
|
||||
/** Where the floating window sits after its last move or resize, for the map to frame around it. */
|
||||
@@ -207,6 +210,9 @@ interface AssistantState {
|
||||
|
||||
setMapHover: (tripId: number, hoveredId: string | null, source: AssistantMapSource) => void;
|
||||
requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') => void;
|
||||
/** Opens a found place in the planner's place form; the planner takes it with `takePlaceDraft`. */
|
||||
openPlaceDraft: (tripId: number, place: AssistantFoundPlace, dayId: number | null) => void;
|
||||
takePlaceDraft: (seq: number) => void;
|
||||
setPreviewPin: (preview: AssistantState['preview']) => void;
|
||||
setWindowRect: (rect: WindowRect | null) => void;
|
||||
setMapView: (mapView: AssistantState['mapView']) => void;
|
||||
@@ -820,6 +826,11 @@ function mapActions(ctx: Ctx) {
|
||||
},
|
||||
requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') =>
|
||||
requestCameraOn(ctx, tripId, points, mode, why),
|
||||
openPlaceDraft: (tripId: number, place: AssistantFoundPlace, dayId: number | null) =>
|
||||
ctx.set({ placeDraft: { tripId, seq: (ctx.get().placeDraft?.seq ?? 0) + 1, place, dayId } }),
|
||||
takePlaceDraft: (seq: number) => {
|
||||
if (ctx.get().placeDraft?.seq === seq) ctx.set({ placeDraft: null });
|
||||
},
|
||||
setPreviewPin: (preview: AssistantState['preview']) => ctx.set({ preview }),
|
||||
setWindowRect: (rect: WindowRect | null) => ctx.set({ windowRect: rect }),
|
||||
setMapView: (mapView: AssistantState['mapView']) => ctx.set({ mapView }),
|
||||
@@ -923,6 +934,7 @@ export const useAssistantStore = create<AssistantState>((set, get) => {
|
||||
mapLink: null,
|
||||
camera: null,
|
||||
preview: null,
|
||||
placeDraft: null,
|
||||
windowRect: null,
|
||||
mapView: null,
|
||||
studioHosts: {},
|
||||
|
||||
@@ -145,6 +145,11 @@ describe('TranslationProvider', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-I18NCTX-018: plural forms follow the language of the strings on screen, not the one still loading', async () => {
|
||||
// The chunk is loaded here first: FE-COMP-I18NCTX-011a starts every locale at
|
||||
// once without waiting, and the Russian one could otherwise queue behind the
|
||||
// others past any timeout. The provider still switches asynchronously, so the
|
||||
// first render below is English all the same.
|
||||
await import('@trek/shared/i18n/ru');
|
||||
withLanguage('ru');
|
||||
render(<TranslationProvider><Probe tKey="places.count" params={{ count: 21 }} /></TranslationProvider>);
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { useLocation } from 'react-router';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { EntityTarget } from '../../../src/components/Assistant/cards/cardTypes';
|
||||
import MAssistantHomeScreen from '../../../src/mobile/screens/assistant/MAssistantHomeScreen';
|
||||
import { chatOf, useAssistantStore } from '../../../src/store/assistantStore';
|
||||
import { act, cleanup, fireEvent, render, screen } from '../../helpers/render';
|
||||
|
||||
// FE-MOB-ASSIST-HOME-001 to FE-MOB-ASSIST-HOME-005
|
||||
//
|
||||
// The phone's home chat, full screen outside a trip, around the shared chat
|
||||
// surface, which is replaced here: what is tested is when it shows, its
|
||||
// head and menu, and the ways out (a trip's own chat, another page).
|
||||
|
||||
const surface = vi.hoisted(() => ({
|
||||
tripId: null as number | null,
|
||||
onOpen: null as ((target: EntityTarget) => void) | null,
|
||||
onFocusDay: null as ((dayId: number) => void) | null,
|
||||
onOpenTripChat: null as ((trip: { id: number; title: string }, question: string | null) => void) | null,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/components/Assistant/AssistantSurfaceLazy', () => ({
|
||||
default: (props: {
|
||||
tripId: number;
|
||||
onOpen?: (target: EntityTarget) => void;
|
||||
onFocusDay?: (dayId: number) => void;
|
||||
onOpenTripChat?: (trip: { id: number; title: string }, question: string | null) => void;
|
||||
}) => {
|
||||
surface.tripId = props.tripId;
|
||||
surface.onOpen = props.onOpen ?? null;
|
||||
surface.onFocusDay = props.onFocusDay ?? null;
|
||||
surface.onOpenTripChat = props.onOpenTripChat ?? null;
|
||||
return <div data-testid="chat-surface" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/components/Assistant/useAssistantThread', () => ({
|
||||
useAssistantThread: () => ({
|
||||
threadId: null,
|
||||
threads: [],
|
||||
title: null,
|
||||
isRunning: false,
|
||||
retentionNote: null,
|
||||
refreshThreads: vi.fn(),
|
||||
openThread: vi.fn(),
|
||||
newThread: vi.fn(),
|
||||
removeThread: vi.fn(async () => undefined),
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const INITIAL = useAssistantStore.getState();
|
||||
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
|
||||
|
||||
function Location() {
|
||||
const { pathname } = useLocation();
|
||||
return <output data-testid="location">{pathname}</output>;
|
||||
}
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<>
|
||||
<MAssistantHomeScreen />
|
||||
<Location />
|
||||
</>,
|
||||
{ initialEntries: [path] }
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
window.history.replaceState(null, '');
|
||||
useAssistantStore.setState({
|
||||
status: STATUS,
|
||||
window: { open: true, minimized: false },
|
||||
loadStatus: vi.fn(async () => undefined),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
useAssistantStore.setState(INITIAL, true);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('MAssistantHomeScreen', () => {
|
||||
it('FE-MOB-ASSIST-HOME-001: asks for the status, and shows nothing closed, without a connection or in a trip', () => {
|
||||
useAssistantStore.setState({ window: { open: false, minimized: false } });
|
||||
const closed = renderAt('/dashboard');
|
||||
expect(useAssistantStore.getState().loadStatus).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
closed.unmount();
|
||||
|
||||
useAssistantStore.setState({ window: { open: true, minimized: false }, status: { ...STATUS, available: false } });
|
||||
const off = renderAt('/atlas');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
off.unmount();
|
||||
|
||||
useAssistantStore.setState({ status: STATUS });
|
||||
renderAt('/trips/4');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASSIST-HOME-002: opens full screen on the home chat, named after all trips, without trip notes', () => {
|
||||
renderAt('/vacay');
|
||||
expect(screen.getByRole('dialog', { name: 'Assistant' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Your travels' })).toBeInTheDocument();
|
||||
expect(surface.tripId).toBe(0);
|
||||
expect(surface.onFocusDay).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More options' }));
|
||||
expect(screen.queryByText('Trip notes')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("FE-MOB-ASSIST-HOME-003: going on in a trip's chat opens the trip with the chat open and the question waiting", () => {
|
||||
renderAt('/dashboard');
|
||||
act(() => surface.onOpenTripChat?.({ id: 9, title: 'Lisbon' }, 'What is missing?'));
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/trips/9');
|
||||
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||
expect(chatOf(useAssistantStore.getState(), 9).draft).toBe('What is missing?');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASSIST-HOME-004: another page closes the chat and opens there; a spot on a map has nowhere to go', () => {
|
||||
renderAt('/dashboard');
|
||||
act(() => surface.onOpen?.({ kind: 'map', placeId: 3, dayId: null }));
|
||||
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||
act(() => surface.onOpen?.({ kind: 'page', path: '/collections' }));
|
||||
expect(useAssistantStore.getState().window.open).toBe(false);
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/collections');
|
||||
});
|
||||
|
||||
it('FE-MOB-ASSIST-HOME-005: the back button closes the chat, and minimized it stays away', () => {
|
||||
const view = renderAt('/settings');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
expect(useAssistantStore.getState().window.open).toBe(false);
|
||||
view.unmount();
|
||||
useAssistantStore.setState({ window: { open: true, minimized: true } });
|
||||
renderAt('/settings');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -173,7 +173,6 @@
|
||||
"client/src/pages/tripPlanner/plannerTypes.ts": 1,
|
||||
"client/src/pages/tripPlanner/useBookingEdits.ts": 1,
|
||||
"client/src/pages/tripPlanner/useDayPlanEdits.ts": 1,
|
||||
"client/src/pages/tripPlanner/usePlaceFormOpeners.ts": 1,
|
||||
"client/src/pages/tripPlanner/useRoadtripAlternatives.ts": 1,
|
||||
"client/src/pages/tripPlanner/useRoadtripStopEdits.ts": 1,
|
||||
"client/src/push/webPush.ts": 1,
|
||||
|
||||
@@ -365,10 +365,17 @@ export const ASSISTANT_MESSAGE_PARTS_WRITE: JsonColumn<AssistantMessagePart[]> =
|
||||
* the provider's side. A broken row reads as none, and the history is rebuilt
|
||||
* from the parts instead.
|
||||
*/
|
||||
const aiProviderStateSchema = z.object({
|
||||
adapter: z.literal('openai-responses'),
|
||||
items: z.array(z.record(z.string(), z.unknown())),
|
||||
});
|
||||
const aiProviderStateSchema = z.discriminatedUnion('adapter', [
|
||||
z.object({
|
||||
adapter: z.literal('openai-responses'),
|
||||
items: z.array(z.record(z.string(), z.unknown())),
|
||||
}),
|
||||
// Anthropic's content blocks of an answer that searched the web, replayed with their search results.
|
||||
z.object({
|
||||
adapter: z.literal('anthropic-messages'),
|
||||
blocks: z.array(z.record(z.string(), z.unknown())),
|
||||
}),
|
||||
]);
|
||||
export type AiProviderState = z.infer<typeof aiProviderStateSchema>;
|
||||
|
||||
export const ASSISTANT_PROVIDER_STATE: JsonColumn<AiProviderState | null> = {
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* An assistant conversation can be about no one trip or journey: the home
|
||||
* chat on the dashboard, Vacay, Atlas, the collections and the settings
|
||||
* knows all of the user's trips and works on what belongs to none of them.
|
||||
* Such a thread names neither `trip_id` nor `journey_id`, so the CHECK that
|
||||
* held every thread to exactly one of the two now only keeps a thread from
|
||||
* naming both. Every existing thread names one of them and stays as it is.
|
||||
*
|
||||
* SQLite cannot change a CHECK in place, so the table is rebuilt the way
|
||||
* Migration20200101046250 rebuilt it: with `foreign_keys = ON` a
|
||||
* `DROP TABLE assistant_threads` runs an implicit `DELETE FROM
|
||||
* assistant_threads` first, and the `ON DELETE CASCADE` on
|
||||
* `assistant_messages` and `assistant_tool_calls` would empty both. So both
|
||||
* are rebuilt as well, pointing at the new tables, and the old ones go
|
||||
* children first. Renaming each new table to its old name rewrites the
|
||||
* foreign keys that point at it. Every id is copied, so a thread, a message
|
||||
* and a proposed change keep theirs, and the indexes come back as they were.
|
||||
*
|
||||
* The home threads of a user are found through the user's trip index
|
||||
* (`user_id` first, then `trip_id` IS NULL); there are few of them.
|
||||
*/
|
||||
/** The three tables rebuilt, children first. */
|
||||
const REBUILT = ['assistant_tool_calls', 'assistant_messages', 'assistant_threads'] as const;
|
||||
|
||||
/** The indexes of the three tables, as they were before the rebuild. */
|
||||
const INDEXES = [
|
||||
'idx_assistant_threads_user_journey ON assistant_threads(user_id, journey_id, last_message_at)',
|
||||
'idx_assistant_threads_journey ON assistant_threads(journey_id)',
|
||||
'idx_assistant_threads_user_trip ON assistant_threads(user_id, trip_id, last_message_at)',
|
||||
'idx_assistant_threads_trip ON assistant_threads(trip_id)',
|
||||
'idx_assistant_messages_thread ON assistant_messages(thread_id, id)',
|
||||
'idx_assistant_tool_calls_message ON assistant_tool_calls(message_id)',
|
||||
] as const;
|
||||
|
||||
export class Migration20200101046280_an_assistant_conversation_can_be_about_no_one_trip extends Migration {
|
||||
override name = 'Migration20200101046280_an_assistant_conversation_can_be_about_no_one_trip';
|
||||
|
||||
override up(): void {
|
||||
this.addSql(`
|
||||
CREATE TABLE assistant_threads_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
trip_id INTEGER REFERENCES trips(id) ON DELETE CASCADE,
|
||||
journey_id INTEGER REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_message_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK(trip_id IS NULL OR journey_id IS NULL)
|
||||
)
|
||||
`);
|
||||
this.addSql(`
|
||||
INSERT INTO assistant_threads_new (id, user_id, trip_id, journey_id, title, created_at, last_message_at)
|
||||
SELECT id, user_id, trip_id, journey_id, title, created_at, last_message_at FROM assistant_threads
|
||||
`);
|
||||
|
||||
this.addSql(`
|
||||
CREATE TABLE assistant_messages_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
thread_id INTEGER NOT NULL REFERENCES assistant_threads_new(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
|
||||
parts TEXT NOT NULL,
|
||||
provider_state TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'complete' CHECK(status IN ('complete', 'awaiting_approval', 'aborted', 'error')),
|
||||
model TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
this.addSql(`
|
||||
INSERT INTO assistant_messages_new (id, thread_id, role, parts, provider_state, status, model, created_at)
|
||||
SELECT id, thread_id, role, parts, provider_state, status, model, created_at FROM assistant_messages
|
||||
`);
|
||||
|
||||
this.addSql(`
|
||||
CREATE TABLE assistant_tool_calls_new (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
thread_id INTEGER NOT NULL REFERENCES assistant_threads_new(id) ON DELETE CASCADE,
|
||||
message_id INTEGER NOT NULL REFERENCES assistant_messages_new(id) ON DELETE CASCADE,
|
||||
provider_call_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
args TEXT NOT NULL,
|
||||
preview TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'approval_requested'
|
||||
CHECK(state IN ('approval_requested', 'approved', 'rejected', 'executed', 'failed', 'expired', 'undone')),
|
||||
result TEXT,
|
||||
undo_until DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
decided_at DATETIME,
|
||||
UNIQUE(thread_id, provider_call_id)
|
||||
)
|
||||
`);
|
||||
this.addSql(`
|
||||
INSERT INTO assistant_tool_calls_new (
|
||||
id, thread_id, message_id, provider_call_id, tool_name, args, preview, state, result, undo_until, created_at,
|
||||
decided_at
|
||||
)
|
||||
SELECT
|
||||
id, thread_id, message_id, provider_call_id, tool_name, args, preview, state, result, undo_until, created_at,
|
||||
decided_at
|
||||
FROM assistant_tool_calls
|
||||
`);
|
||||
|
||||
// Children first, so no old table is referenced when it goes; then each new one takes its old name.
|
||||
for (const table of REBUILT) this.addSql(`DROP TABLE ${table}`);
|
||||
for (const table of [...REBUILT].reverse()) this.addSql(`ALTER TABLE ${table}_new RENAME TO ${table}`);
|
||||
for (const index of INDEXES) this.addSql(`CREATE INDEX IF NOT EXISTS ${index}`);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import type { DB } from '../kysely/db';
|
||||
import type { AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/** One `assistant_threads` row as stored: about a trip or about a journey, never both. */
|
||||
import type { ExpressionBuilder } from 'kysely';
|
||||
|
||||
/** One `assistant_threads` row as stored: about a trip, about a journey, or (home) about neither; never both. */
|
||||
export interface AssistantThreadRow {
|
||||
id: number;
|
||||
user_id: number;
|
||||
@@ -33,13 +35,23 @@ type RetentionKyselyDB = Pick<
|
||||
/** The column a thread names what it is about with. */
|
||||
type OwnerColumn = 'trip_id' | 'journey_id';
|
||||
|
||||
/** What a thread is about: a trip or a journey by its column, or null for the home chat, which names neither. */
|
||||
type ThreadOwner = { column: OwnerColumn; id: number } | null;
|
||||
|
||||
/** The rows of one owner: its column on its id, or for the home chat both columns empty. */
|
||||
function ownedBy(owner: ThreadOwner) {
|
||||
return (eb: ExpressionBuilder<ThreadsKyselyDB, 'assistant_threads'>) =>
|
||||
owner ? eb(owner.column, '=', owner.id) : eb.and([eb('trip_id', 'is', null), eb('journey_id', 'is', null)]);
|
||||
}
|
||||
|
||||
const COLUMNS = ['id', 'user_id', 'trip_id', 'journey_id', 'title', 'created_at', 'last_message_at'] as const;
|
||||
|
||||
/**
|
||||
* `assistant_threads`: a user's conversations about one trip, or (the Studio's)
|
||||
* about one journey. Every read and delete a request makes names the user and
|
||||
* the trip or journey next to the id, so a thread is only ever reached by its
|
||||
* owner on its own trip or journey.
|
||||
* `assistant_threads`: a user's conversations about one trip, (the Studio's)
|
||||
* about one journey, or (the home chat's, on the dashboard and the pages
|
||||
* outside a trip) about neither. Every read and delete a request makes names
|
||||
* the user and the trip, the journey or the home chat next to the id, so a
|
||||
* thread is only ever reached by its owner in its own scope.
|
||||
*/
|
||||
export class AssistantThreadsRepository extends TrekRepository<AssistantThreads> {
|
||||
private db() {
|
||||
@@ -66,22 +78,42 @@ export class AssistantThreadsRepository extends TrekRepository<AssistantThreads>
|
||||
return row.id;
|
||||
}
|
||||
|
||||
/** A new thread of the home chat, about no one trip or journey; answers its id, as `createThread` does. */
|
||||
async createHomeThread(userId: number, title: string, at: string): Promise<number> {
|
||||
const row = await this.db()
|
||||
.insertInto('assistant_threads')
|
||||
.values({ user_id: userId, title, created_at: at, last_message_at: at })
|
||||
.returning('id')
|
||||
.executeTakeFirstOrThrow();
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async findOwned(id: number, userId: number, tripId: number): Promise<AssistantThreadRow | null> {
|
||||
return this.findOwnedBy(id, userId, 'trip_id', tripId);
|
||||
return this.findOwnedBy(id, userId, { column: 'trip_id', id: tripId });
|
||||
}
|
||||
|
||||
async findOwnedOnJourney(id: number, userId: number, journeyId: number): Promise<AssistantThreadRow | null> {
|
||||
return this.findOwnedBy(id, userId, 'journey_id', journeyId);
|
||||
return this.findOwnedBy(id, userId, { column: 'journey_id', id: journeyId });
|
||||
}
|
||||
|
||||
/** One of the user's home threads; null for a trip's or a journey's, or anyone else's. */
|
||||
async findOwnedAtHome(id: number, userId: number): Promise<AssistantThreadRow | null> {
|
||||
return this.findOwnedBy(id, userId, null);
|
||||
}
|
||||
|
||||
/** The user's threads on a trip, the most recently used first. */
|
||||
async listRecent(userId: number, tripId: number, limit: number): Promise<AssistantThreadRow[]> {
|
||||
return this.listRecentBy(userId, 'trip_id', tripId, limit);
|
||||
return this.listRecentBy(userId, { column: 'trip_id', id: tripId }, limit);
|
||||
}
|
||||
|
||||
/** The user's threads on a journey, the most recently used first. */
|
||||
async listRecentOnJourney(userId: number, journeyId: number, limit: number): Promise<AssistantThreadRow[]> {
|
||||
return this.listRecentBy(userId, 'journey_id', journeyId, limit);
|
||||
return this.listRecentBy(userId, { column: 'journey_id', id: journeyId }, limit);
|
||||
}
|
||||
|
||||
/** The user's home threads, the most recently used first. */
|
||||
async listRecentAtHome(userId: number, limit: number): Promise<AssistantThreadRow[]> {
|
||||
return this.listRecentBy(userId, null, limit);
|
||||
}
|
||||
|
||||
async touch(id: number, at: string): Promise<void> {
|
||||
@@ -90,12 +122,17 @@ export class AssistantThreadsRepository extends TrekRepository<AssistantThreads>
|
||||
|
||||
/** True when the thread was the user's on that trip and is gone now. */
|
||||
async deleteOwned(id: number, userId: number, tripId: number): Promise<boolean> {
|
||||
return this.deleteOwnedBy(id, userId, 'trip_id', tripId);
|
||||
return this.deleteOwnedBy(id, userId, { column: 'trip_id', id: tripId });
|
||||
}
|
||||
|
||||
/** True when the thread was the user's on that journey and is gone now. */
|
||||
async deleteOwnedOnJourney(id: number, userId: number, journeyId: number): Promise<boolean> {
|
||||
return this.deleteOwnedBy(id, userId, 'journey_id', journeyId);
|
||||
return this.deleteOwnedBy(id, userId, { column: 'journey_id', id: journeyId });
|
||||
}
|
||||
|
||||
/** True when the thread was one of the user's home threads and is gone now. */
|
||||
async deleteOwnedAtHome(id: number, userId: number): Promise<boolean> {
|
||||
return this.deleteOwnedBy(id, userId, null);
|
||||
}
|
||||
|
||||
/** Retention: drops the threads nobody wrote in since `cutoff`; answers how many went. */
|
||||
@@ -113,7 +150,9 @@ export class AssistantThreadsRepository extends TrekRepository<AssistantThreads>
|
||||
* A journey thread goes once its user neither owns the journey nor is on its
|
||||
* contributor list nor is named by one of its trip links. A link member who
|
||||
* left the linking trip keeps the thread until the link drops them; every
|
||||
* request checks the access itself, so such a thread is never reached.
|
||||
* request checks the access itself, so such a thread is never reached. A
|
||||
* home thread is about nothing anyone can lose access to: it stays until it
|
||||
* idles past the retention or its user goes.
|
||||
*/
|
||||
async deleteWithoutAccess(): Promise<number> {
|
||||
const result = await this.kysely<RetentionKyselyDB>()
|
||||
@@ -178,45 +217,35 @@ export class AssistantThreadsRepository extends TrekRepository<AssistantThreads>
|
||||
return Number(result.numDeletedRows);
|
||||
}
|
||||
|
||||
private async findOwnedBy(
|
||||
id: number,
|
||||
userId: number,
|
||||
column: OwnerColumn,
|
||||
ownerId: number,
|
||||
): Promise<AssistantThreadRow | null> {
|
||||
private async findOwnedBy(id: number, userId: number, owner: ThreadOwner): Promise<AssistantThreadRow | null> {
|
||||
const row = await this.db()
|
||||
.selectFrom('assistant_threads')
|
||||
.select(COLUMNS)
|
||||
.where('id', '=', id)
|
||||
.where('user_id', '=', userId)
|
||||
.where(column, '=', ownerId)
|
||||
.where(ownedBy(owner))
|
||||
.executeTakeFirst();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
private async listRecentBy(
|
||||
userId: number,
|
||||
column: OwnerColumn,
|
||||
ownerId: number,
|
||||
limit: number,
|
||||
): Promise<AssistantThreadRow[]> {
|
||||
private async listRecentBy(userId: number, owner: ThreadOwner, limit: number): Promise<AssistantThreadRow[]> {
|
||||
return this.db()
|
||||
.selectFrom('assistant_threads')
|
||||
.select(COLUMNS)
|
||||
.where('user_id', '=', userId)
|
||||
.where(column, '=', ownerId)
|
||||
.where(ownedBy(owner))
|
||||
.orderBy('last_message_at', 'desc')
|
||||
.orderBy('id', 'desc')
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async deleteOwnedBy(id: number, userId: number, column: OwnerColumn, ownerId: number): Promise<boolean> {
|
||||
private async deleteOwnedBy(id: number, userId: number, owner: ThreadOwner): Promise<boolean> {
|
||||
const result = await this.db()
|
||||
.deleteFrom('assistant_threads')
|
||||
.where('id', '=', id)
|
||||
.where('user_id', '=', userId)
|
||||
.where(column, '=', ownerId)
|
||||
.where(ownedBy(owner))
|
||||
.executeTakeFirst();
|
||||
return Number(result.numDeletedRows) > 0;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user