feat(assistant): a card framework every addon package plugs into

This commit is contained in:
Maurice
2026-10-22 12:14:24 +02:00
parent d2a2728cde
commit 3967ca2790
514 changed files with 9868 additions and 1274 deletions
+8
View File
@@ -19,6 +19,7 @@ import {
type AssistantDecisionsRequest,
type AssistantErrorCode,
type AssistantShareRequest,
type AssistantTakeProposalRequest,
type AssistantThreadListResponse,
type AssistantThreadResponse,
type AssistantUndoResponse,
@@ -122,6 +123,13 @@ export const assistantApi = {
body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
takeProposal: (
tripId: number,
threadId: number,
body: AssistantTakeProposalRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/proposals`, body).then((r) => r.data),
// An answer posted to the trip chat, marked as shared from the assistant; the client's socket id skips the echo.
share: (
tripId: number,
@@ -65,6 +65,7 @@ const pending = (id: string, subject: string): ChangeItem => ({
preview: preview(id, subject),
applied: null,
state: 'pending',
card: null,
});
const onTheMap = (id: string, subject: string, effect = PANTHEON): ChangeItem => ({
@@ -74,7 +75,7 @@ const onTheMap = (id: string, subject: string, effect = PANTHEON): ChangeItem =>
function receipt(id: string, undoUntil: string | null): ChangeItem {
const applied: AssistantApplied = { toolCallId: id, entity: 'place', ids: [9], dayId: 3, undoUntil };
return { toolCallId: id, preview: preview(id, 'Kinkaku-ji'), applied, state: 'applied' };
return { toolCallId: id, preview: preview(id, 'Kinkaku-ji'), applied, state: 'applied', card: null };
}
function renderCard(items: ChangeItem[], view: Partial<AssistantView> = {}) {
@@ -173,12 +174,12 @@ describe('AssistantChangeCard: the receipt', () => {
it('FE-AI-CARD-005: an applied change shows on the map and can be undone until its window closes', () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date('2026-10-21T08:00:00.000Z'));
const onShowOnMap = vi.fn();
renderCard([receipt('c1', '2026-10-21T08:15:00.000Z')], { onShowOnMap });
const onOpen = vi.fn();
renderCard([receipt('c1', '2026-10-21T08:15:00.000Z')], { onOpen });
expect(screen.getByRole('img', { name: 'Applied' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
expect(onShowOnMap).toHaveBeenCalledWith(expect.objectContaining({ toolCallId: 'c1', dayId: 3 }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 9, dayId: null });
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
act(() => {
@@ -259,12 +260,12 @@ describe('AssistantChangeCard: on the map', () => {
it('FE-AI-CARD-011: on the phone the card hops to the map and frames what it would change', () => {
plannerHere();
const onShowOnMap = vi.fn();
const first = renderCard([onTheMap('c1', 'Pantheon')], { onShowOnMap });
const onOpen = vi.fn();
const first = renderCard([onTheMap('c1', 'Pantheon')], { onOpen });
expect(screen.queryByRole('button', { name: 'View on map' })).toBeNull();
first.unmount();
renderCard([onTheMap('c1', 'Pantheon')], { variant: 'fullscreen', onShowOnMap });
renderCard([onTheMap('c1', 'Pantheon')], { variant: 'fullscreen', onOpen });
fireEvent.click(screen.getByRole('button', { name: 'View on map' }));
expect(requestCamera).toHaveBeenCalledWith(
TRIP,
@@ -285,14 +286,14 @@ describe('AssistantChangeCard: the applied block', () => {
it('FE-AI-CARD-012: before and after are there only for the block this session can compare, beside a map', () => {
compare('c1');
const onShowOnMap = vi.fn();
const first = renderCard([receipt('c1', null), receipt('c2', null)], { onShowOnMap });
const onOpen = vi.fn();
const first = renderCard([receipt('c1', null), receipt('c2', null)], { onOpen });
expect(screen.getByRole('button', { name: 'After' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Before' }));
expect(setCompare).toHaveBeenCalledWith(TRIP, 'before');
first.unmount();
const other = renderCard([receipt('c7', null), receipt('c8', null)], { onShowOnMap });
const other = renderCard([receipt('c7', null), receipt('c8', null)], { onOpen });
expect(screen.queryByRole('button', { name: 'Before' })).toBeNull();
other.unmount();
@@ -1,25 +1,4 @@
import type { AssistantApplied, AssistantChangePreview, AssistantChangeVerbKey } from '@trek/shared';
import {
CalendarArrowUp,
CalendarMinus,
CalendarPlus,
Check,
CircleSlash,
ListTodo,
LoaderCircle,
Luggage,
MapPin,
MapPinPlus,
NotebookPen,
PencilLine,
Plus,
StickyNote,
Ticket,
Trash2,
TriangleAlert,
Undo2,
Wallet,
} from 'lucide-react';
import { Check, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
@@ -28,14 +7,15 @@ import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import { useToast } from '../shared/Toast';
import { Tooltip } from '../shared/Tooltip';
import AssistantChangeFields from './AssistantChangeFields';
import AssistantCompareSwitch from './AssistantCompareSwitch';
import AssistantFitCheck from './AssistantFitCheck';
import AssistantWeekOverview from './AssistantWeekOverview';
import { type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts';
import { type ChangeItem, undoLeftMs } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import { ChangeRows } from './cards/ChangeCardSlots';
import { ICON_BUTTON } from './cards/parts/assistantTones';
import { useChangeExclusions } from './cards/useChangeExclusions';
import { changePoints, type DayChange } from './dayAfterModel';
import { useChangeHover } from './useChangeHover';
import { useWeekDays } from './useWeekDays';
/**
@@ -49,7 +29,8 @@ import { useWeekDays } from './useWeekDays';
* Every change wears its kind: a tinted tile with the icon of what it touches
* and a badge naming the verb, in the tone of what happens (green adds, blue
* changes, amber moves, red takes away). Its fields sit as badges holding a
* label badge, so a row of them reads at a glance.
* label badge, so a row of them reads at a glance. The rows themselves, and a
* change that carries a card of its own, are drawn in cards/ChangeCardSlots.
*
* While it waits, the card shows the days it touches as they would read
* afterwards, and each row lights its pin on the map (and the pin its row).
@@ -57,106 +38,6 @@ import { useWeekDays } from './useWeekDays';
* map, and taking the whole block back at once.
*/
type Tone = 'success' | 'info' | 'warning' | 'danger';
const TILE: Record<Tone, string> = {
success: 'bg-success-soft text-success',
info: 'bg-info-soft text-info',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
};
/** What each kind of change looks like. A table, so a new verb fails to compile until it has a look. */
const VERB_LOOK: Record<AssistantChangeVerbKey, { Icon: typeof Check; tone: Tone }> = {
addPlace: { Icon: MapPinPlus, tone: 'success' },
addPlaceToDay: { Icon: CalendarPlus, tone: 'success' },
updatePlace: { Icon: PencilLine, tone: 'info' },
assignPlace: { Icon: CalendarPlus, tone: 'success' },
moveAssignment: { Icon: CalendarArrowUp, tone: 'warning' },
removeFromDay: { Icon: CalendarMinus, tone: 'danger' },
addDayNote: { Icon: StickyNote, tone: 'success' },
updateDayNote: { Icon: StickyNote, tone: 'info' },
addPackingItem: { Icon: Luggage, tone: 'success' },
updatePackingItem: { Icon: Luggage, tone: 'info' },
addTodo: { Icon: ListTodo, tone: 'success' },
addBudgetItem: { Icon: Wallet, tone: 'success' },
updateBudgetItem: { Icon: Wallet, tone: 'info' },
addReservation: { Icon: Ticket, tone: 'success' },
importBooking: { Icon: Ticket, tone: 'success' },
rememberNote: { Icon: NotebookPen, tone: 'info' },
addTour: { Icon: MapPinPlus, tone: 'success' },
updateTour: { Icon: PencilLine, tone: 'info' },
importTour: { Icon: Plus, tone: 'success' },
genericCreate: { Icon: Plus, tone: 'success' },
genericUpdate: { Icon: PencilLine, tone: 'info' },
genericRemove: { Icon: Trash2, tone: 'danger' },
};
/** How a decided change reads: one quiet mark, its words in the tooltip. */
const RECEIPT: Record<Exclude<ChangeState, 'pending'>, { labelKey: string; Icon: typeof Check; tone: string }> = {
applying: { labelKey: 'assistant.change.applying', Icon: LoaderCircle, tone: 'text-content-muted' },
applied: { labelKey: 'assistant.change.applied', Icon: Check, tone: 'text-success' },
failed: { labelKey: 'assistant.change.failed', Icon: TriangleAlert, tone: 'text-danger' },
discarded: { labelKey: 'assistant.change.discarded', Icon: CircleSlash, tone: 'text-content-faint' },
expired: { labelKey: 'assistant.change.expired', Icon: CircleSlash, tone: 'text-content-faint' },
};
const ICON_BUTTON =
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-50';
function KindTile({
verbKey,
size = 'md',
dimmed = false,
}: {
verbKey: AssistantChangeVerbKey | null;
size?: 'sm' | 'md';
dimmed?: boolean;
}) {
const box = size === 'sm' ? 'h-7 w-7 rounded-md' : 'h-8 w-8 rounded-lg';
if (!verbKey) return <span className={`trek-skeleton flex-shrink-0 ${box}`} aria-hidden="true" />;
const { Icon, tone } = VERB_LOOK[verbKey];
return (
<span
aria-hidden="true"
className={`flex flex-shrink-0 items-center justify-center ${box} ${dimmed ? 'bg-surface-secondary text-content-faint' : TILE[tone]}`}
>
<Icon size={size === 'sm' ? 14 : 16} strokeWidth={2.1} />
</span>
);
}
/** The key of a tool's own action name, for the tools TREK names one for. */
const toolKey = (tool: string) => `assistant.tool.${tool}`;
/** What the change does: the tool's own action where TREK names it, else add, change or remove. */
function verbLabel(preview: AssistantChangePreview, t: (key: string) => string): string {
const own = preview.tool ? toolKey(preview.tool) : null;
const named = own ? t(own) : null;
return named && named !== own ? named : t(`assistant.change.verb.${preview.verbKey}`);
}
function ChangeHead({ preview, struck = false }: { preview: AssistantChangePreview | null; struck?: boolean }) {
const { t } = useTranslation();
if (!preview) return <span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />;
return (
<span className="flex min-w-0 flex-col">
<bdi className={`truncate text-body font-semibold leading-tight text-content ${struck ? 'line-through' : ''}`}>
{preview.subject}
</bdi>
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
<span className="truncate">{verbLabel(preview, t)}</span>
{preview.domain && (
// Where in TREK a change outside the trip plan lands: the vacation planner, the journal.
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
{t(`assistant.domain.${preview.domain}`)}
</span>
)}
</span>
</span>
);
}
/** The time now, ticking once more when the next undo window closes. */
function useUndoClock(items: ChangeItem[]): number {
const [now, setNow] = useState(() => Date.now());
@@ -188,134 +69,6 @@ function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string
);
}
/** What an applied change can be shown as on the map; a kept note, a todo or an expense has no place there. */
const ON_MAP: ReadonlySet<AssistantApplied['entity']> = new Set(['place', 'assignment', 'day_note', 'reservation']);
function Receipt({
item,
undone,
canUndo,
onUndo,
}: {
item: ChangeItem;
undone: boolean;
canUndo: boolean;
onUndo: () => void;
}) {
const { t } = useTranslation();
const { onShowOnMap } = useAssistantView();
if (item.state === 'pending') return null;
const receipt = undone ? RECEIPT.discarded : RECEIPT[item.state];
const { Icon } = receipt;
const applied = item.state === 'applied' && !undone ? item.applied : null;
const mappable = applied !== null && ON_MAP.has(applied.entity);
const settled = item.state === 'discarded' || item.state === 'expired' || undone;
const statusLabel = t(undone ? 'assistant.change.undone' : receipt.labelKey);
return (
<div className="trek-assistant-enter flex items-center gap-2.5 py-1.5">
<KindTile verbKey={item.preview?.verbKey ?? null} size="sm" dimmed={settled} />
<div className={`min-w-0 flex-1 ${settled ? 'opacity-60' : ''}`}>
<ChangeHead preview={item.preview} struck={settled} />
</div>
{applied && mappable && onShowOnMap && (
<Tooltip label={t('assistant.change.showOnMap')} placement="top">
<button
type="button"
onClick={() => onShowOnMap(applied)}
aria-label={t('assistant.change.showOnMap')}
className={ICON_BUTTON}
>
<MapPin size={14} aria-hidden="true" />
</button>
</Tooltip>
)}
{canUndo && (
<Tooltip label={t('assistant.change.undo')} placement="top">
<button type="button" onClick={onUndo} aria-label={t('assistant.change.undo')} className={ICON_BUTTON}>
<Undo2 size={14} aria-hidden="true" />
</button>
</Tooltip>
)}
<Tooltip label={statusLabel} placement="top">
<span
role="img"
aria-label={statusLabel}
className={`flex h-7 w-5 items-center justify-center ${receipt.tone}`}
>
<Icon size={15} strokeWidth={2.4} className={item.state === 'applying' ? 'animate-spin' : ''} />
</span>
</Tooltip>
</div>
);
}
/**
* A change waiting for its decision, as a row of the planner's day list: the
* round choice in front (only when there is more than one to choose from),
* what it touches in bold, and a faint line saying what happens and when.
* Resting on it lights its pin on the map.
*/
function PendingRow({
item,
excluded,
withChoice,
disabled,
onToggle,
}: {
item: ChangeItem;
excluded: boolean;
withChoice: boolean;
disabled: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const { litFromMap, handlers } = useChangeHover(tripId, item.preview?.effect ? item.toolCallId : null);
const preview = item.preview;
return (
<div
{...handlers}
className={`-mx-1.5 flex items-center gap-2.5 rounded-[12px] px-1.5 py-2 transition-[opacity,background-color] ${
excluded ? 'opacity-50' : ''
} ${litFromMap ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
>
{withChoice && (
<button
type="button"
onClick={onToggle}
disabled={disabled}
aria-pressed={!excluded}
aria-label={t('assistant.change.include')}
className={`grid h-[18px] w-[18px] flex-none place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 ${
excluded ? 'border-[1.5px] border-edge bg-surface-card' : 'bg-accent'
}`}
>
{!excluded && <Check size={11} strokeWidth={3} aria-hidden="true" className="text-accent-text" />}
</button>
)}
<KindTile verbKey={preview?.verbKey ?? null} size="sm" dimmed={excluded} />
<div className="min-w-0 flex-1">
{preview ? (
<>
<bdi className="block truncate text-body font-semibold leading-tight text-content">{preview.subject}</bdi>
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-caption text-content-faint">
<span className="me-0.5 min-w-0 truncate">{verbLabel(preview, t)}</span>
{preview.domain && (
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
{t(`assistant.domain.${preview.domain}`)}
</span>
)}
<AssistantChangeFields rows={preview.rows} />
</span>
</>
) : (
<span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />
)}
</div>
</div>
);
}
/** The card's head: what the card is, what can be done with it as a whole, and how many changes it holds. */
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
const { t } = useTranslation();
@@ -399,10 +152,10 @@ function HeadActions({
changes: readonly DayChange[];
undoable: readonly string[];
}) {
const { tripId, variant, onShowOnMap } = useAssistantView();
const { tripId, variant, onOpen } = useAssistantView();
const here = useTripStore((s) => s.trip?.id === tripId);
if (open) {
const toMap = variant === 'fullscreen' && !!onShowOnMap && here && changes.length > 0;
const toMap = variant === 'fullscreen' && !!onOpen && here && changes.length > 0;
return (
<>
<AssistantFitCheck changes={changes} />
@@ -412,7 +165,7 @@ function HeadActions({
}
return (
<>
{onShowOnMap && <AssistantCompareSwitch tripId={tripId} blockKey={blockKeyOf(items.map((i) => i.toolCallId))} />}
{onOpen && <AssistantCompareSwitch tripId={tripId} blockKey={blockKeyOf(items.map((i) => i.toolCallId))} />}
{undoable.length >= 2 && <UndoAllButton toolCallIds={undoable} />}
</>
);
@@ -426,7 +179,7 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
const undone = useAssistantStore((s) => chatOf(s, tripId).undone);
const decide = useAssistantStore((s) => s.decide);
const undo = useAssistantStore((s) => s.undo);
const [excluded, setExcluded] = useState<ReadonlySet<string>>(() => new Set());
const [excluded, setExcluded] = useChangeExclusions(items);
const now = useUndoClock(items);
const pending = items.filter((i) => i.state === 'pending');
@@ -489,30 +242,16 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
{t('assistant.week.showAll', { count: pending.length })}
</button>
)}
<div>
{items.map((item) =>
item.state === 'pending' ? (
folded ? null : (
<PendingRow
key={item.toolCallId}
item={item}
excluded={excluded.has(item.toolCallId)}
withChoice={pending.length > 1}
disabled={isRunning}
onToggle={() => toggle(item.toolCallId)}
/>
)
) : (
<Receipt
key={item.toolCallId}
item={item}
undone={!!undone[item.toolCallId]}
canUndo={undoable.includes(item.toolCallId)}
onUndo={() => void runUndo(item.toolCallId)}
/>
)
)}
</div>
<ChangeRows
items={items}
folded={folded}
excluded={excluded}
disabled={isRunning}
undone={undone}
undoable={undoable}
onToggle={toggle}
onUndo={(toolCallId) => void runUndo(toolCallId)}
/>
{open && (
<>
<p className="sr-only" role="status">
@@ -1,6 +1,5 @@
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
import {
ArrowRight,
CalendarClock,
CalendarDays,
Clock,
@@ -14,7 +13,7 @@ import {
} from 'lucide-react';
import { useTranslation } from '../../i18n';
import { SoftPill } from '../Planner/planParts';
import { FactChange } from './cards/parts/CardFacts';
/**
* The few facts that tell a change apart at a glance, as badges on the line
@@ -57,22 +56,14 @@ export default function AssistantChangeFields({ rows }: { rows: AssistantChangeR
return (
<>
{facts.map((row, i) => {
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
const changed = row.op !== 'add' && !!row.before;
return (
<SoftPill
<FactChange
key={`${row.labelKey}-${i}`}
className="max-w-full tabular-nums"
icon={<Icon size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />}
>
{changed && (
<>
<bdi className="truncate text-content-faint line-through">{shown(row, row.before, dayWord)}</bdi>
<ArrowRight size={9} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
</>
)}
<bdi className="truncate">{shown(row, row.after, dayWord)}</bdi>
</SoftPill>
icon={KEY_FIELDS[row.labelKey] ?? Tag}
before={changed ? shown(row, row.before, dayWord) : null}
after={shown(row, row.after, dayWord)}
/>
);
})}
</>
@@ -1,10 +1,10 @@
/**
* FE-AI-HOST-001 to FE-AI-HOST-005
* FE-AI-HOST-001 to FE-AI-HOST-007
*
* The desktop host follows the trip on screen and sends "Show on map" and a
* picked day to the planner through its links.
* The desktop host follows the trip on screen and sends what the chat opens
* (a spot on the map, a tab of the trip, another page) and a picked day to
* the planner through its links.
*/
import type { AssistantApplied } from '@trek/shared';
import { useLocation } from 'react-router';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -12,10 +12,11 @@ import { act, render, screen } from '../../../tests/helpers/render';
import { useTripStore } from '../../store/tripStore';
import type { Trip } from '../../types';
import AssistantHost from './AssistantHost';
import type { EntityTarget } from './cards/cardTypes';
interface WindowProps {
trip: { id: number; title: string } | null;
onShowOnMap?: (applied: AssistantApplied) => void;
onOpen?: (target: EntityTarget) => void;
onFocusDay?: (dayId: number) => void;
}
const seen: { props: WindowProps | null } = { props: null };
@@ -42,19 +43,12 @@ function renderAt(path: string) {
);
}
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
toolCallId: 'c1',
entity,
ids,
dayId,
undoUntil: null,
});
const INITIAL = useTripStore.getState();
afterEach(() => {
useTripStore.setState(INITIAL, true);
seen.props = null;
sessionStorage.clear();
});
describe('AssistantHost', () => {
@@ -73,13 +67,29 @@ describe('AssistantHost', () => {
it('FE-AI-HOST-003: "Show on map" opens the planner on the place, or on the day', () => {
renderAt('/trips/5/files');
act(() => seen.props?.onShowOnMap?.(applied('place', [7], 2)));
act(() => seen.props?.onOpen?.({ kind: 'map', placeId: 7, dayId: null }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?place=7');
act(() => seen.props?.onShowOnMap?.(applied('day_note', [9], 3)));
act(() => seen.props?.onOpen?.({ kind: 'map', placeId: null, dayId: 3 }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?day=3');
});
it('FE-AI-HOST-006: a tab of the trip opens as ?tab= with its params, a list tab remembers its own list first', () => {
renderAt('/trips/5');
act(() => seen.props?.onOpen?.({ kind: 'tab', tab: 'finanzplan', params: { expense: '7' } }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?tab=finanzplan&expense=7');
act(() => seen.props?.onOpen?.({ kind: 'tab', tab: 'listen', sub: 'todo' }));
expect(screen.getByTestId('location')).toHaveTextContent('/trips/5?tab=listen');
expect(sessionStorage.getItem('trip-lists-subtab-5')).toBe('todo');
});
it('FE-AI-HOST-007: another page is navigated to as it is', () => {
renderAt('/trips/5');
act(() => seen.props?.onOpen?.({ kind: 'page', path: '/atlas' }));
expect(screen.getByTestId('location')).toHaveTextContent('/atlas');
});
it('FE-AI-HOST-005: a day picked in the chat selects it in the planner', () => {
renderAt('/trips/5');
act(() => seen.props?.onFocusDay?.(12));
@@ -1,10 +1,11 @@
import type { AssistantApplied } from '@trek/shared';
import { useCallback, useMemo } from 'react';
import { useLocation, useMatch, useNavigate } from 'react-router';
import { rememberListsSubTab } from '../../hooks/useListsSubTab';
import { useTripStore } from '../../store/tripStore';
import AssistantWindow from './AssistantWindow';
import { showOnMapSearch } from './assistantShowOnMap';
import { plannerSearchOf } from './assistantShowOnMap';
import type { EntityTarget } from './cards/cardTypes';
/** The trip a route under /trips/:id shows, or null anywhere else. */
function useRouteTripId(): number | null {
@@ -15,10 +16,12 @@ 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; "Show
* on map" opens the planner on the change through its links (`?place=`,
* `?day=`), the same way a notification opens a place, and a day picked in
* the chat (its weather) selects that day the same way.
* keeps going from page to page. Inside a trip it is about that trip; what a
* card or an applied change opens goes through the planner's links: "Show on
* map" opens the planner on the change (`?place=`, `?day=`), the same way a
* notification opens a place, a tab of the trip opens as `?tab=` (a list's
* 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.
*/
export default function AssistantHost() {
const navigate = useNavigate();
@@ -36,8 +39,20 @@ export default function AssistantHost() {
},
[navigate, pathname, tripId]
);
const showOnMap = useCallback((applied: AssistantApplied) => openPlanner(showOnMapSearch(applied)), [openPlanner]);
const open = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page') {
void navigate(target.path);
return;
}
if (target.kind === 'tab' && target.tab === 'listen' && tripId !== null) {
if (target.sub === 'packing' || target.sub === 'todo') rememberListsSubTab(tripId, target.sub);
}
openPlanner(plannerSearchOf(target));
},
[navigate, openPlanner, tripId]
);
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
return <AssistantWindow trip={trip} onShowOnMap={showOnMap} onFocusDay={focusDay} />;
return <AssistantWindow trip={trip} onOpen={open} onFocusDay={focusDay} />;
}
@@ -16,14 +16,16 @@ import AssistantToolSteps from './AssistantToolSteps';
import AssistantWeatherStrip from './AssistantWeatherStrip';
import { messageBlocks } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import AssistantCardBlock from './cards/AssistantCardBlock';
import { useAssistantShare } from './useAssistantShare';
/**
* One message of the chat. The user's question sits on the end side in a
* quiet accent bubble; the answer runs as plain text across the column, with
* its looked-up steps, the places and weather they found, and the proposed
* changes in between, and a copy and a share button that show on hover
* (always on the latest answer, and always on the phone, which has no hover).
* its looked-up steps, the places and weather they found, the cards its
* tools brought and the proposed changes in between, and a copy and a share
* button that show on hover (always on the latest answer, and always on the
* phone, which has no hover).
*/
const BAR_BUTTON =
@@ -127,6 +129,7 @@ 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 === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
return <AssistantChangeCard key={block.key} items={block.items} />;
})}
{/* Nothing below says the answer goes on: no words being written, no step running. */}
@@ -1,132 +1,40 @@
import type { AssistantMessage, AssistantPlaces } from '@trek/shared';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import AssistantPlaceCard from './AssistantPlaceCard';
import { suggestedFromStrip } from './assistantParts';
import { useAssistantView } from './assistantViewContext';
import CardStrip from './cards/parts/CardStrip';
import { useAssistantPlaceStrip } from './useAssistantPlaceStrip';
/** Cards the strip shows, every one the server sends; what it left out is counted on the tile at its end. */
const SHOWN = 60;
// In the strip's head, never over a card: a press that ends a pixel off still lands on the arrow.
const ARROW =
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content disabled:cursor-default disabled:opacity-40 disabled:hover:text-content-secondary';
/** How far the strip has scrolled from either end, with the reading direction in mind. */
function edgesOf(el: HTMLElement): { start: boolean; end: boolean } {
// A right-to-left strip scrolls into negative numbers; the distance from the start is the same either way.
const from = Math.abs(el.scrollLeft);
return { start: from > 4, end: from + el.clientWidth < el.scrollWidth - 4 };
}
/**
* The places a search found, as one row of cards to swipe or scroll through:
* the next card peeks in, so the row says it goes on, and the edges fade
* where there is more. On the desktop a head over the row names it and holds
* two arrows that move it by whole cards, off while that way is the end. Every card the step found, then a
* tile counting what the server left out.
* The places a search found, as one row of cards to swipe or scroll through
* (the cards' strip: the next card peeks in, the edges fade where there is
* more, and the desktop head names the row and pages it by whole cards).
* Every card the step found, then a tile counting what the server left out.
*/
export default function AssistantPlaceStrip({ found, message }: { found: AssistantPlaces; message: AssistantMessage }) {
const { t } = useTranslation();
const { variant } = useAssistantView();
const strip = useAssistantPlaceStrip();
const listRef = useRef<HTMLUListElement>(null);
const [fade, setFade] = useState({ start: false, end: false });
const rest = Math.max(0, found.places.length - SHOWN) + found.more;
const measure = useCallback(() => {
const el = listRef.current;
if (!el) return;
const next = edgesOf(el);
setFade((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
}, []);
useEffect(() => {
measure();
const el = listRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure, found]);
// The pin a card left on the map goes with the strip.
const { hover } = strip;
useEffect(() => () => hover(null), [hover]);
/**
* Scrolls by as many whole cards as the view holds, towards the start (-1)
* or the end (1), so the row lands on a card edge instead of fighting its
* snap points half way; smoothly, unless motion is turned down.
*/
const page = (towards: -1 | 1) => {
const el = listRef.current;
const card = el?.firstElementChild as HTMLElement | null;
if (!el || !card) return;
const gap = Number.parseFloat(getComputedStyle(el).columnGap) || 0;
const step = card.offsetWidth + gap;
const cards = Math.max(1, Math.floor((el.clientWidth - 32) / step));
const rtl = getComputedStyle(el).direction === 'rtl';
// Smooth through the strip's own scroll-behavior, which reduced motion turns off.
el.scrollBy((rtl ? -towards : towards) * cards * step, 0);
};
const total = found.places.length + found.more;
return (
<section aria-label={t('assistant.place.strip')} className="-mx-4">
{variant === 'window' && (fade.start || fade.end) && (
// The cards are reachable by Tab; the arrows are for the pointer only.
<div className="mb-1.5 flex items-center gap-2 px-4">
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-muted">
{t('assistant.place.strip')}
<span className="ms-1.5 tabular-nums text-content-faint">{total}</span>
</span>
<button
type="button"
tabIndex={-1}
aria-hidden="true"
disabled={!fade.start}
onClick={() => page(-1)}
className={ARROW}
>
<ChevronLeft size={15} className="rtl:-scale-x-100" />
</button>
<button
type="button"
tabIndex={-1}
aria-hidden="true"
disabled={!fade.end}
onClick={() => page(1)}
className={ARROW}
>
<ChevronRight size={15} className="rtl:-scale-x-100" />
</button>
</div>
<CardStrip label={t('assistant.place.strip')} count={total} watch={found}>
{found.places.slice(0, SHOWN).map((place) => (
<AssistantPlaceCard
key={place.ref}
place={place}
strip={strip}
suggested={suggestedFromStrip(message, place)}
/>
))}
{rest > 0 && (
<li className="flex w-24 flex-shrink-0 snap-start items-center justify-center rounded-2xl border border-dashed border-edge-faint px-2 text-center text-caption text-content-muted">
{t('assistant.place.more', { count: rest })}
</li>
)}
<ul
ref={listRef}
onScroll={measure}
data-fade-start={fade.start || undefined}
data-fade-end={fade.end || undefined}
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5 motion-safe:scroll-smooth"
>
{found.places.slice(0, SHOWN).map((place) => (
<AssistantPlaceCard
key={place.ref}
place={place}
strip={strip}
suggested={suggestedFromStrip(message, place)}
/>
))}
{rest > 0 && (
<li className="flex w-24 flex-shrink-0 snap-start items-center justify-center rounded-2xl border border-dashed border-edge-faint px-2 text-center text-caption text-content-muted">
{t('assistant.place.more', { count: rest })}
</li>
)}
</ul>
</section>
</CardStrip>
);
}
@@ -153,6 +153,7 @@ describe('the week grid', () => {
preview: preview(id, dayId),
applied: null,
state: 'pending',
card: null,
});
it('FE-AI-POWER-005: from three days on the changes group by day, in trip order', () => {
@@ -22,7 +22,7 @@ export default function AssistantSurface({
tripTitle,
variant,
density,
onShowOnMap,
onOpen,
onFocusDay,
onRevealMap,
}: AssistantSurfaceProps) {
@@ -33,11 +33,11 @@ export default function AssistantSurface({
tripId,
variant,
density: variant === 'fullscreen' ? ('compact' as const) : (density ?? 'roomy'),
onShowOnMap,
onOpen,
onFocusDay,
onRevealMap,
}),
[tripId, variant, density, onShowOnMap, onFocusDay, onRevealMap]
[tripId, variant, density, onOpen, onFocusDay, onRevealMap]
);
return (
<AssistantRuntimeProvider runtime={runtime}>
@@ -1,5 +1,5 @@
/**
* FE-AI-WIN-001 to FE-AI-WIN-010
* FE-AI-WIN-001 to FE-AI-WIN-011
*
* 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
@@ -8,6 +8,7 @@
* useDragResize's and has its own tests; here it is a fixed rect.
*/
import type { AiStatusResponse } from '@trek/shared';
import { useLocation } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
@@ -15,6 +16,7 @@ import { tripRepo } from '../../repo/tripRepo';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import type { Trip } from '../../types';
import AssistantWindow from './AssistantWindow';
import type { EntityTarget } from './cards/cardTypes';
vi.mock('../../hooks/useDragResize', () => ({
useDragResize: () => ({
@@ -32,11 +34,13 @@ vi.mock('./AssistantSurfaceLazy', () => ({
tripTitle,
density,
onFocusDay,
onOpen,
}: {
tripId: number;
tripTitle: string;
density?: string;
onFocusDay?: (dayId: number) => void;
onOpen?: (target: EntityTarget) => void;
}) => (
<div>
<p>
@@ -48,12 +52,30 @@ vi.mock('./AssistantSurfaceLazy', () => ({
pick day
</button>
)}
{onOpen && (
<>
<button type="button" onClick={() => onOpen({ kind: 'map', placeId: 7, dayId: null })}>
open place
</button>
<button type="button" onClick={() => onOpen({ kind: 'tab', tab: 'finanzplan', params: { expense: '3' } })}>
open tab
</button>
<button type="button" onClick={() => onOpen({ kind: 'page', path: '/atlas' })}>
open page
</button>
</>
)}
<textarea aria-label="composer" />
</div>
),
}));
const TRIP = { id: 4, title: 'Kyoto' };
function Location() {
const { pathname, search } = useLocation();
return <output data-testid="location">{`${pathname}${search}`}</output>;
}
const INITIAL = useAssistantStore.getState();
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
@@ -185,4 +207,34 @@ describe('AssistantWindow', () => {
unmount();
expect(useAssistantStore.getState().windowRect).toBeNull();
});
it('FE-AI-WIN-011: opens through the planner on screen, keeps a trip it leaves, and opens a picked trip itself', async () => {
seed({ open: true, minimized: false });
const onOpen = vi.fn();
const onScreen = render(<AssistantWindow trip={TRIP} onOpen={onOpen} />);
fireEvent.click(screen.getByRole('button', { name: 'open place' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 7, dayId: null });
// Another page leaves the trip; the chat stays on it as if it had been picked.
fireEvent.click(screen.getByRole('button', { name: 'open page' }));
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'page', path: '/atlas' });
onScreen.rerender(<AssistantWindow trip={null} onOpen={onOpen} />);
expect(screen.getByText('chat 4 Kyoto')).toBeInTheDocument();
onScreen.unmount();
seed({ open: true, minimized: false });
render(
<>
<AssistantWindow trip={null} onOpen={onOpen} />
<Location />
</>
);
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');
fireEvent.click(screen.getByRole('button', { name: 'open place' }));
expect(screen.getByTestId('location')).toHaveTextContent(/^\/trips\/9$/);
fireEvent.click(screen.getByRole('button', { name: 'open page' }));
expect(screen.getByTestId('location')).toHaveTextContent('/atlas');
expect(onOpen).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,5 @@
import './assistant.css';
import type { AssistantApplied } from '@trek/shared';
import { Sparkles, X } from 'lucide-react';
import { type CSSProperties, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
@@ -12,6 +11,8 @@ import AssistantHeader from './AssistantHeader';
import AssistantLauncher, { LAUNCHER_INSET, LAUNCHER_SIZE } from './AssistantLauncher';
import AssistantSurfaceLazy from './AssistantSurfaceLazy';
import AssistantTripPicker from './AssistantTripPicker';
import { plannerSearchOf } from './assistantShowOnMap';
import type { EntityTarget } from './cards/cardTypes';
import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantThread } from './useAssistantThread';
@@ -39,8 +40,8 @@ interface WindowTrip {
interface AssistantWindowProps {
/** The trip on screen; null outside a trip, where the window asks which one first. */
trip: WindowTrip | null;
/** Shows an applied change in the planner on screen. */
onShowOnMap?: (applied: AssistantApplied) => void;
/** Opens what a card or an applied change points at, from the trip on screen. */
onOpen?: (target: EntityTarget) => void;
/** Selects a day in the planner on screen. */
onFocusDay?: (dayId: number) => void;
}
@@ -101,12 +102,12 @@ function originFrom(rect: WindowRect, rtl: boolean): string {
function ChatWindow({
trip,
hidden,
onShowOnMap,
onOpen,
onFocusDay,
}: {
trip: WindowTrip;
hidden: boolean;
onShowOnMap?: (applied: AssistantApplied) => void;
onOpen?: (target: EntityTarget) => void;
onFocusDay?: (dayId: number) => void;
}) {
const { t } = useTranslation();
@@ -209,7 +210,7 @@ function ChatWindow({
tripTitle={trip.title}
variant="window"
density={width < ROOMY_WIDTH ? 'compact' : 'roomy'}
onShowOnMap={onShowOnMap}
onOpen={onOpen}
onFocusDay={onFocusDay}
/>
</div>
@@ -217,7 +218,7 @@ function ChatWindow({
);
}
export default function AssistantWindow({ trip, onShowOnMap, onFocusDay }: AssistantWindowProps) {
export default function AssistantWindow({ trip, onOpen, onFocusDay }: AssistantWindowProps) {
const navigate = useNavigate();
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open);
@@ -239,23 +240,34 @@ export default function AssistantWindow({ trip, onShowOnMap, onFocusDay }: Assis
}
const current = trip ?? picked;
const showPicked = useCallback(() => {
if (picked) void navigate(`/trips/${picked.id}`);
}, [navigate, picked]);
// A change in a trip the planner does not show is shown by opening that trip; a tab opens on it.
const openPicked = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page') void navigate(target.path);
else if (picked) void navigate(`/trips/${picked.id}${target.kind === 'tab' ? plannerSearchOf(target) : ''}`);
},
[navigate, picked]
);
// Another page leaves the trip: the chat stays on it, as if it had been picked.
const openOnScreen = useCallback(
(target: EntityTarget) => {
if (target.kind === 'page' && trip) setPicked(trip);
onOpen?.(target);
},
[onOpen, trip]
);
if (!available) return null;
if (!open) return trip ? <AssistantLauncher variant="floating" tripId={trip.id} /> : null;
if (!current) return <PickerWindow onPick={setPicked} />;
// A change in a trip the planner does not show is shown by opening that trip.
const onScreen = !!trip && current.id === trip.id;
const showOnMap = onScreen ? onShowOnMap : showPicked;
return (
<>
<ChatWindow
trip={current}
hidden={minimized}
onShowOnMap={showOnMap}
onOpen={onScreen ? onOpen && openOnScreen : openPicked}
// Only the planner on screen has days to select.
onFocusDay={onScreen ? onFocusDay : undefined}
/>
@@ -0,0 +1,44 @@
/**
* FE-AI-FOLLOW-001 to FE-AI-FOLLOW-002
*
* The follow-ups a card offers under an answer that ends on it: the ones that
* ask for a change only for someone who may make it, and the answer's own
* when none is left for a reader.
*/
import type { AssistantMessage } from '@trek/shared';
import { afterEach, describe, expect, it } from 'vitest';
import { followUpsFor } from './assistantFollowUpRules';
import { CARD_LOOKS } from './cards/cardLooks';
const answer: AssistantMessage = {
id: 'a1',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-22T08:00:00.000Z',
parts: [
{ type: 'text', text: 'Here is your year.' },
{ type: 'tool-call', toolCallId: 'r1', toolName: 'get_vacay_plan', args: {}, result: {} },
{ type: 'data-trek-card', data: { toolCallId: 'r1', card: { kind: 'vacay' } } },
],
};
afterEach(() => {
delete CARD_LOOKS.vacay.followUps;
});
describe('card follow-ups', () => {
it('FE-AI-FOLLOW-001: a follow-up that asks for a change is offered only to someone who may make it', () => {
CARD_LOOKS.vacay.followUps = [
{ id: 'planDays', labelKey: 'assistant.followUp.next', edits: true },
{ id: 'holidays', labelKey: 'assistant.followUp.more' },
];
expect(followUpsFor(answer, true).map((f) => f.id)).toEqual(['planDays', 'holidays']);
expect(followUpsFor(answer, false).map((f) => f.id)).toEqual(['holidays']);
});
it('FE-AI-FOLLOW-002: with no card follow-up left for a reader, the answer offers its own', () => {
CARD_LOOKS.vacay.followUps = [{ id: 'planDays', labelKey: 'assistant.followUp.next', edits: true }];
expect(followUpsFor(answer, false).map((f) => f.id)).toEqual(['more', 'next']);
});
});
@@ -1,15 +1,21 @@
import type { AssistantMessage } from '@trek/shared';
import { messageBlocks } from './assistantParts';
import { CARD_LOOKS } from './cards/cardLooks';
/**
* The next questions an answer suggests, as one tap each: read from what the
* answer holds, not asked from the model, so they cost nothing and come at
* once. Places found ask to narrow them, a plan asks to go on, a forecast asks
* for the other weather. Three at most; the first kind the answer holds wins.
* once. Cards ask what their kind offers, places found ask to narrow them, a
* plan asks to go on, a forecast asks for the other weather. Three at most;
* the first kind the answer holds wins.
*/
export interface FollowUp {
id: string;
labelKey: string;
/** Asks for a change, so only someone who may edit the trip is offered it. */
edits?: boolean;
}
const PLACES: readonly FollowUp[] = [
@@ -34,8 +40,23 @@ const GENERAL: readonly FollowUp[] = [
{ id: 'next', labelKey: 'assistant.followUp.next' },
];
/**
* The follow-ups of the cards an answer ends on (past its closing words): what
* the first card's kind offers, laid out as the chat lays it out, without the
* ones that ask for a change when the user may not make one.
*/
function cardFollowUps(message: AssistantMessage, canEdit: boolean): readonly FollowUp[] {
const blocks = messageBlocks(message, false).filter((block) => block.kind !== 'text');
const last = blocks[blocks.length - 1];
const kind = last?.kind === 'cards' ? last.entries[0]?.card.kind : undefined;
const offered = kind ? (CARD_LOOKS[kind].followUps ?? []) : [];
return canEdit ? offered : offered.filter((followUp) => !followUp.edits);
}
/** What an answer offers to ask next; changes are offered only to someone who may make them. */
export function followUpsFor(message: AssistantMessage, canEdit: boolean): readonly FollowUp[] {
const fromCards = cardFollowUps(message, canEdit);
if (fromCards.length > 0) return fromCards;
const types = new Set(message.parts.map((part) => part.type));
if (canEdit && (types.has('data-trek-change') || types.has('data-trek-applied'))) return PLAN;
if (types.has('data-trek-places')) return PLACES;
@@ -1,12 +1,15 @@
import type { ThreadMessageLike } from '@assistant-ui/react';
import type { AiStatusResponse, AssistantErrorCode, AssistantMessage, AssistantMessagePart } from '@trek/shared';
import { STEP_LOOKS } from './cards/cardLooks';
/**
* Pure helpers the assistant's views share, desktop window and phone screen
* alike: the bridge from stored messages to @assistant-ui, what an error says
* and offers, how a reading tool is named while it runs, and which questions
* the empty chat suggests. No React, and only a type from @assistant-ui, so
* importing this keeps the chat runtime out of the chunk.
* and offers, how a reading tool is named while it runs (the steps live in
* the cards' look), and which questions the empty chat suggests. No React,
* and only a type from @assistant-ui, so importing this keeps the chat
* runtime out of the chunk.
*/
type LikePart = Exclude<ThreadMessageLike['content'], string>[number];
@@ -144,47 +147,11 @@ export function connectionErrorOf(status: AiStatusResponse | null): AssistantErr
// ── Tool steps ──────────────────────────────────────────────────────────────
/** `liveKey` names the step with the place or query it is at, `param` is the word that fills it. */
interface StepLook {
stepKey: string;
live?: { liveKey: string; param: 'place' | 'query' };
}
const STEPS: Partial<Record<string, StepLook>> = {
get_trip_summary: { stepKey: 'assistant.step.reading' },
list_places: { stepKey: 'assistant.step.reading' },
search_place: {
stepKey: 'assistant.step.searching',
live: { liveKey: 'assistant.step.searchingFor', param: 'query' },
},
search_pois: { stepKey: 'assistant.step.searching', live: { liveKey: 'assistant.step.nearbyAt', param: 'place' } },
get_place_details: { stepKey: 'assistant.step.details' },
search_nearby_places: {
stepKey: 'assistant.step.nearby',
live: { liveKey: 'assistant.step.nearbyAt', param: 'place' },
},
list_categories: { stepKey: 'assistant.step.categories' },
get_weather: { stepKey: 'assistant.step.weather', live: { liveKey: 'assistant.step.weatherAt', param: 'place' } },
list_todos: { stepKey: 'assistant.step.todos' },
get_settlement_summary: { stepKey: 'assistant.step.costs' },
list_budget_items: { stepKey: 'assistant.step.expenses' },
get_budget_per_person: { stepKey: 'assistant.step.shares' },
get_wind_forecast: { stepKey: 'assistant.step.wind' },
get_journey_tracks: { stepKey: 'assistant.step.tracks' },
scan_bucket_list_visits: { stepKey: 'assistant.step.bucketScan' },
suggest_atlas_countries: { stepKey: 'assistant.step.atlasCountries' },
propose_hike: {
stepKey: 'assistant.step.planningHike',
live: { liveKey: 'assistant.step.planningHikeTo', param: 'place' },
},
search_help: { stepKey: 'assistant.step.help' },
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
};
const WORKING = { stepKey: 'assistant.step.working' };
/** How a reading tool is named while it runs: "Reading your plan", never the tool's name. */
export function toolStepKey(toolName: string): string {
return STEPS[toolName]?.stepKey ?? WORKING.stepKey;
return STEP_LOOKS[toolName]?.stepKey ?? WORKING.stepKey;
}
/**
@@ -193,7 +160,7 @@ export function toolStepKey(toolName: string): string {
* or the step has no such line.
*/
export function liveStepText(toolName: string, label: string | null): { key: string; params?: Record<string, string> } {
const live = STEPS[toolName]?.live;
const live = STEP_LOOKS[toolName]?.live;
if (!live || !label) return { key: toolStepKey(toolName) };
return { key: live.liveKey, params: { [live.param]: label } };
}
@@ -1,9 +1,10 @@
/**
* FE-AI-PARTS-001 to FE-AI-PARTS-014
* FE-AI-PARTS-001 to FE-AI-PARTS-019
*
* How an answer is laid out: text, the steps the assistant looked up, the
* places and the weather they found, and one card per batch of proposed
* changes, each change in the state its approval and receipt put it in.
* places and the weather they found, the cards its tools brought, and one
* card per batch of proposed changes, each change in the state its approval
* and receipt put it in, with its own card.
*/
import {
ASSISTANT_CHANGE_LABEL_KEYS,
@@ -13,10 +14,18 @@ import {
type AssistantMessage,
type AssistantMessagePart,
type AssistantWeatherDay,
type TrekCard,
} from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { CHANGE_LABELS, hasUndecided, messageBlocks, suggestedFromStrip, undoLeftMs } from './assistantParts';
import {
CHANGE_LABELS,
type CardRules,
hasUndecided,
messageBlocks,
suggestedFromStrip,
undoLeftMs,
} from './assistantParts';
const AT = '2026-10-21T08:00:00.000Z';
@@ -294,6 +303,94 @@ describe('messageBlocks: found places and weather', () => {
});
});
describe('messageBlocks: cards', () => {
const ROUTE: TrekCard = { kind: 'tourRoute' };
const LIST: TrekCard = { kind: 'tourList' };
const card = (toolCallId: string, value: TrekCard | null): AssistantMessagePart => ({
type: 'data-trek-card',
data: { toolCallId, card: value },
});
it('FE-AI-PARTS-015: a read card joins the cards of its kind across steps, and the steps stay one row', () => {
const blocks = messageBlocks(
message([read('r1', {}), card('r1', LIST), read('r2', {}), card('r2', LIST), read('r3', {}), card('r3', ROUTE)]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'cards', 'cards']);
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 'r1' }, { toolCallId: 'r2' }, { toolCallId: 'r3' }] });
expect(blocks[1]).toMatchObject({ group: 'tourList', entries: [{ toolCallId: 'r1' }, { toolCallId: 'r2' }] });
expect(blocks[2]).toMatchObject({ group: 'tourRoute', entries: [{ toolCallId: 'r3', card: ROUTE }] });
});
it('FE-AI-PARTS-016: text between cards starts a new block; a card that did not read leaves nothing', () => {
const blocks = messageBlocks(
message([card('r1', LIST), { type: 'text', text: 'And more:' }, card('r2', LIST), card('r3', null)]),
false
);
expect(blocks.map((b) => b.kind)).toEqual(['cards', 'text', 'cards']);
expect(blocks[2]).toMatchObject({ entries: [{ toolCallId: 'r2' }] });
});
it('FE-AI-PARTS-017: a change carries its preview card until the applied card replaces it, and forms no block', () => {
const held = { ...preview('w1'), card: ROUTE };
const before = messageBlocks(message([write('w1'), { type: 'data-trek-change', data: held }]), false);
expect(before).toHaveLength(1);
expect(before[0]).toMatchObject({ kind: 'change', items: [{ toolCallId: 'w1', card: ROUTE }] });
const after = messageBlocks(
message([
write('w1', { approved: true }, { result: {} }),
{ type: 'data-trek-change', data: held },
{ type: 'data-trek-applied', data: applied('w1') },
card('w1', LIST),
]),
false
);
expect(after.map((b) => b.kind)).toEqual(['change']);
expect(after[0]).toMatchObject({ items: [{ toolCallId: 'w1', state: 'applied', card: LIST }] });
const plain = messageBlocks(message([write('w2'), { type: 'data-trek-change', data: preview('w2') }]), false);
expect(plain[0]).toMatchObject({ items: [{ card: null }] });
});
it('FE-AI-PARTS-018: the rules group cards, and a lookup card that served a change gives way to it', () => {
const rules: CardRules = {
group: () => 'tours',
yieldsToChange: (answer, change) => answer.kind === 'tourList' && change.kind === 'tourRoute',
};
const parts: AssistantMessagePart[] = [
card('r1', LIST),
card('r2', ROUTE),
write('w1'),
{ type: 'data-trek-change', data: { ...preview('w1'), card: ROUTE } },
];
const blocks = messageBlocks(message(parts), false, rules);
expect(blocks.map((b) => b.kind)).toEqual(['cards', 'change']);
expect(blocks[0]).toMatchObject({ group: 'tours', entries: [{ toolCallId: 'r2' }] });
// Without a change card nothing gives way; with the plain rules each kind is its own group.
const plain = messageBlocks(message(parts.slice(0, 2)), false);
expect(plain.map((b) => (b.kind === 'cards' ? b.group : b.kind))).toEqual(['tourList', 'tourRoute']);
});
it('FE-AI-PARTS-019: a block left with no card goes, and a step after the cards joins the steps before them', () => {
const rules: CardRules = { group: (c) => c.kind, yieldsToChange: () => true };
const blocks = messageBlocks(
message([
read('r1', {}),
card('r1', LIST),
read('r2', {}),
write('w1'),
{ type: 'data-trek-change', data: { ...preview('w1'), card: ROUTE } },
]),
false,
rules
);
expect(blocks.map((b) => b.kind)).toEqual(['steps', 'change']);
expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 'r1' }, { toolCallId: 'r2' }] });
});
});
describe('hasUndecided', () => {
it('FE-AI-PARTS-012: is true only while a change waits for the user', () => {
expect(hasUndecided([message([write('w1')])])).toBe(true);
@@ -8,16 +8,21 @@ import type {
AssistantMessagePart,
AssistantPlaces,
AssistantWeatherDay,
TrekCard,
} from '@trek/shared';
import { CARD_RULES } from './cards/cardLooks';
import type { CardEntry } from './cards/cardTypes';
/**
* How an answer is laid out: its stored parts read as blocks. Text stays
* text; the reading tools of a run sit together as one row of steps; the
* changes the model proposes in one go sit together as one card, each with
* its preview and, once applied, its receipt. The places a search found sit
* as one strip, the newest search's, and the weather the steps looked up as
* one row of days. Pure, so the window and the phone screen lay an answer out
* the same way and a test can pin it.
* one row of days. The cards of reading tools sit as blocks of their group; a
* write's card rides on its change. Pure, so the window and the phone screen
* lay an answer out the same way and a test can pin it.
*/
/** Where the settings card for the user's own AI connection lives. */
@@ -47,14 +52,26 @@ export interface ChangeItem {
/** The receipt of an applied change. */
applied: AssistantApplied | null;
state: ChangeState;
/** What the change does as a card: the applied one once it came, else the preview's; null without one. */
card: TrekCard | null;
}
/** The answer cards of one group, side by side. */
export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] };
type MessageBlock =
| { kind: 'text'; key: string; text: string }
| { kind: 'steps'; key: string; steps: ToolStep[] }
| { kind: 'change'; key: string; items: ChangeItem[] }
| { kind: 'places'; key: string; found: AssistantPlaces }
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] };
| { kind: 'weather'; key: string; days: AssistantWeatherDay[] }
| CardsBlock;
/** How cards are laid out: the group each joins, and which ones give way to a change they served. */
export interface CardRules {
group(card: TrekCard): string;
yieldsToChange(answer: TrekCard, change: TrekCard): boolean;
}
/** The change "+ Day 3" on a found place makes; the server holds it without a model turn. */
const FOUND_PLACE_TOOL = 'add_found_place';
@@ -126,16 +143,59 @@ function pushChange(blocks: MessageBlock[], item: ChangeItem, fromStrip: Readonl
else blocks.push({ kind: 'change', key: `change-${item.toolCallId}`, items: [item] });
}
/** The steps block a new step joins: the last one, past any strip shown after it. */
/** The steps block a new step joins: the last one, past any strip or cards shown after it. */
function openSteps(blocks: readonly MessageBlock[]): Extract<MessageBlock, { kind: 'steps' }> | null {
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i];
if (block?.kind === 'steps') return block;
if (block?.kind !== 'places' && block?.kind !== 'weather') return null;
if (block?.kind !== 'places' && block?.kind !== 'weather' && block?.kind !== 'cards') return null;
}
return null;
}
/** Adds an answer card to the last cards block of its group when only steps lie between them, or starts one. */
function pushCard(blocks: MessageBlock[], entry: CardEntry, group: string): void {
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i];
if (block?.kind === 'steps') continue;
if (block?.kind === 'cards' && block.group === group) {
block.entries.push(entry);
return;
}
break;
}
blocks.push({ kind: 'cards', key: `cards-${entry.toolCallId}`, group, entries: [entry] });
}
/** The blocks without the answer cards that give way to one of the message's changes, and without emptied ones. */
function withoutYielded(blocks: MessageBlock[], rules: CardRules): MessageBlock[] {
const changes = blocks.flatMap((b) => (b.kind === 'change' ? b.items.flatMap((i) => (i.card ? [i.card] : [])) : []));
if (changes.length === 0) return blocks;
return blocks.flatMap((block) => {
if (block.kind !== 'cards') return [block];
const entries = block.entries.filter((e) => !changes.some((change) => rules.yieldsToChange(e.card, change)));
return entries.length > 0 ? [{ ...block, entries }] : [];
});
}
/** The change a call holds, with its preview, receipt and card; null for a reading step. */
function changeItemOf(
part: ToolCallPart,
preview: AssistantChangePreview | null,
receipts: ReadonlyMap<string, AssistantApplied>,
cards: ReadonlyMap<string, TrekCard>
): ChangeItem | null {
if (!part.approval && !preview) return null;
const applied = receipts.get(part.toolCallId) ?? null;
return {
toolCallId: part.toolCallId,
preview,
applied,
state: changeState(part, applied),
card: cards.get(part.toolCallId) ?? preview?.card ?? null,
};
}
function changeState(part: ToolCallPart, applied: AssistantApplied | null): ChangeState {
const approval = part.approval;
if (approval?.resolution) return 'expired';
@@ -157,10 +217,21 @@ function changeState(part: ToolCallPart, applied: AssistantApplied | null): Chan
* answer already holds; the weather of all steps joins
* into one row where the first look sat. A strip does not split the steps
* around it: a step after it joins the row of steps before it.
*
* A reading tool's card joins the cards block of its group (by `rules`, the
* kinds' looks unless a test hands others) when only steps lie between them;
* a card under a change's id is that change's and forms no block. Last,
* answer cards that give way to one of the message's changes are left out.
*/
export function messageBlocks(message: AssistantMessage, writing: boolean): MessageBlock[] {
export function messageBlocks(
message: AssistantMessage,
writing: boolean,
rules: CardRules = CARD_RULES
): MessageBlock[] {
const previews = new Map<string, AssistantChangePreview>();
const receipts = new Map<string, AssistantApplied>();
const cards = new Map<string, TrekCard>();
const changeIds = new Set<string>();
const effects: AssistantChangeEffect[] = [];
// A place the user suggested from the strip stays on it, marked as suggested.
const fromStrip = new Set(
@@ -172,9 +243,12 @@ export function messageBlocks(message: AssistantMessage, writing: boolean): Mess
if (part.type === 'data-trek-change') {
previews.set(part.data.toolCallId, part.data);
if (part.data.effect && !fromStrip.has(part.data.toolCallId)) effects.push(part.data.effect);
changeIds.add(part.data.toolCallId);
} else if (part.type === 'data-trek-applied') receipts.set(part.data.toolCallId, part.data);
else if (part.type === 'data-trek-places') lastPlaces = index;
else if (part.type === 'data-trek-weather' && firstWeather < 0) firstWeather = index;
else if (part.type === 'data-trek-card' && part.data.card) cards.set(part.data.toolCallId, part.data.card);
else if (part.type === 'tool-call' && part.approval) changeIds.add(part.toolCallId);
});
const blocks: MessageBlock[] = [];
@@ -193,11 +267,15 @@ export function messageBlocks(message: AssistantMessage, writing: boolean): Mess
blocks.push({ kind: 'weather', key: `weather-${index}`, days: weatherDays(message.parts) });
return;
}
if (part.type === 'data-trek-card') {
const { toolCallId, card } = part.data;
// A card under a change's id is that change's; it rides on its row.
if (card && !changeIds.has(toolCallId)) pushCard(blocks, { toolCallId, card }, rules.group(card));
return;
}
if (part.type !== 'tool-call') return;
const preview = previews.get(part.toolCallId) ?? null;
if (part.approval || preview) {
const applied = receipts.get(part.toolCallId) ?? null;
const item: ChangeItem = { toolCallId: part.toolCallId, preview, applied, state: changeState(part, applied) };
const item = changeItemOf(part, previews.get(part.toolCallId) ?? null, receipts, cards);
if (item) {
pushChange(blocks, item, fromStrip);
return;
}
@@ -210,7 +288,7 @@ export function messageBlocks(message: AssistantMessage, writing: boolean): Mess
if (steps) steps.steps.push(step);
else blocks.push({ kind: 'steps', key: `steps-${part.toolCallId}`, steps: [step] });
});
return blocks;
return withoutYielded(blocks, rules);
}
/**
@@ -1,6 +1,7 @@
import type { AssistantMessage } from '@trek/shared';
import { messageBlocks } from './assistantParts';
import { verbLookOf } from './cards/cardLooks';
/**
* What an answer says once it goes to the trip chat, which shows plain text:
@@ -102,7 +103,8 @@ export function chatTextOf(message: AssistantMessage, opts: { includeChanges: bo
const previews = blocks.flatMap((b) =>
b.kind === 'change' ? b.items.flatMap((i) => (i.state === 'applied' && i.preview ? [i.preview] : [])) : []
);
const lines = previews.map((p) => `✓ ${opts.t(`assistant.change.verb.${p.verbKey}`)}: ${p.subject}`);
// Named by its verb, as the share text always was; a card never goes along (it may hold what is private).
const lines = previews.map((p) => `✓ ${opts.t(verbLookOf(p.verbKey).labelKey)}: ${p.subject}`);
const heading = opts.t('assistant.share.changesHeading');
const changes = opts.includeChanges && lines.length > 0 ? [heading, ...lines].join('\n') : '';
const full = [body, changes].filter(Boolean).join('\n\n');
@@ -1,6 +1,6 @@
import type { AssistantApplied } from '@trek/shared';
import { showOnMapSearch, showOnMapTarget } from './assistantShowOnMap';
import { plannerSearchOf, showOnMapTarget } from './assistantShowOnMap';
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
toolCallId: 'c1',
@@ -10,19 +10,34 @@ const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: numbe
undoUntil: null,
});
/** The planner's query for what "Show on map" points an applied change at. */
const searchOf = (change: AssistantApplied) => {
const target = showOnMapTarget(change);
return target ? plannerSearchOf({ kind: 'map', ...target }) : null;
};
describe('assistantShowOnMap', () => {
it('FE-ASSIST-SHOW-001: a place goes to the place, even when it was put on a day', () => {
expect(showOnMapTarget(applied('place', [7], 3))).toEqual({ placeId: 7, dayId: null });
expect(showOnMapSearch(applied('place', [7], 3))).toBe('?place=7');
expect(searchOf(applied('place', [7], 3))).toBe('?place=7');
});
it('FE-ASSIST-SHOW-002: a stop or a note goes to its day', () => {
expect(showOnMapSearch(applied('assignment', [41], 3))).toBe('?day=3');
expect(showOnMapSearch(applied('day_note', [9], 4))).toBe('?day=4');
it('FE-ASSIST-SHOW-002: a stop, a note or a booking goes to its day', () => {
expect(searchOf(applied('assignment', [41], 3))).toBe('?day=3');
expect(searchOf(applied('day_note', [9], 4))).toBe('?day=4');
expect(searchOf(applied('reservation', [5], 2))).toBe('?day=2');
});
it('FE-ASSIST-SHOW-003: a change with nowhere to point at opens the planner as it is', () => {
expect(showOnMapTarget(applied('packing', [2], null))).toEqual({ placeId: null, dayId: null });
expect(showOnMapSearch(applied('place', [], null))).toBe('');
it('FE-ASSIST-SHOW-003: no target where nothing shows on the map; a place without a spot opens the planner', () => {
expect(showOnMapTarget(applied('packing', [2], null))).toBeNull();
expect(showOnMapTarget(applied('tour', [2], 3))).toBeNull();
expect(showOnMapTarget(applied('memory', [], null))).toBeNull();
expect(searchOf(applied('place', [], null))).toBe('');
});
it('FE-ASSIST-SHOW-004: a tab of the trip goes as ?tab= with what the tab reads beside it', () => {
const expense = plannerSearchOf({ kind: 'tab', tab: 'finanzplan', params: { expense: '7' } });
expect(expense).toBe('?tab=finanzplan&expense=7');
expect(plannerSearchOf({ kind: 'tab', tab: 'listen', sub: 'todo' })).toBe('?tab=listen');
});
});
@@ -1,4 +1,7 @@
import type { AssistantApplied } from '@trek/shared';
import type { AssistantApplied, TrekCard } from '@trek/shared';
import { entityLookOf } from './cards/cardLooks';
import type { EntityTarget } from './cards/cardTypes';
interface ShowOnMapTarget {
/** The place the change made or changed. */
@@ -7,16 +10,23 @@ interface ShowOnMapTarget {
dayId: number | null;
}
/** Where "Show on map" takes an applied change: the place when it is one, else its day; nothing for the rest. */
export function showOnMapTarget(applied: AssistantApplied): ShowOnMapTarget {
const placeId = applied.entity === 'place' ? (applied.ids[0] ?? null) : null;
return { placeId, dayId: placeId === null ? applied.dayId : null };
/**
* Where "Show on map" takes an applied change, as its entity's look says: a
* place shows itself, a stop or a note its day. Null for what has no place on
* the map (a kept note, a to-do), which then shows no map button at all.
*/
export function showOnMapTarget(applied: AssistantApplied, card: TrekCard | null = null): ShowOnMapTarget | null {
return entityLookOf(applied.entity).onMap?.(applied, card) ?? null;
}
/** The planner's query for it: `?place=` opens the place, `?day=` selects the day. */
export function showOnMapSearch(applied: AssistantApplied): string {
const { placeId, dayId } = showOnMapTarget(applied);
if (placeId !== null) return `?place=${placeId}`;
if (dayId !== null) return `?day=${dayId}`;
/**
* The planner's query for a target on the trip: `?place=` opens the place,
* `?day=` selects the day, and a tab goes as `?tab=` with whatever the tab
* reads beside it (`?tab=finanzplan&expense=7`).
*/
export function plannerSearchOf(target: Exclude<EntityTarget, { kind: 'page' }>): string {
if (target.kind === 'tab') return `?${new URLSearchParams({ tab: target.tab, ...target.params }).toString()}`;
if (target.placeId !== null) return `?place=${target.placeId}`;
if (target.dayId !== null) return `?day=${target.dayId}`;
return '';
}
@@ -1,6 +1,7 @@
import type { AssistantApplied } from '@trek/shared';
import { createContext, useContext } from 'react';
import type { EntityTarget } from './cards/cardTypes';
/** What the parts deep inside a chat need from whoever shows it. */
export interface AssistantView {
tripId: number;
@@ -8,8 +9,12 @@ export interface AssistantView {
variant: 'window' | 'fullscreen';
/** How much room the chat has: a narrow window and the phone are 'compact'. */
density?: 'compact' | 'roomy';
/** Shows an applied change in the planner; the button is left out where nothing can show it. */
onShowOnMap?: (applied: AssistantApplied) => void;
/**
* Opens what a card or an applied change points at: a spot on the plan's
* map, a tab of the trip, another page. The buttons are left out where
* nothing can open them.
*/
onOpen?: (target: EntityTarget) => void;
/** Selects a day in the planner, from its weather; left out where no planner can show it. */
onFocusDay?: (dayId: number) => void;
/** Brings the map into view where the chat covers it (the phone), before the map moves. */
@@ -0,0 +1,74 @@
/**
* FE-AI-CARDBLOCK-001 to FE-AI-CARDBLOCK-003
*
* A block of answer cards is drawn by its kind's view; a kind not drawn yet
* shows nothing, and a card that throws is left out without the answer
* around it.
*/
import type { AssistantMessage, TrekCard } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import type { CardsBlock } from '../assistantParts';
import AssistantCardBlock from './AssistantCardBlock';
vi.mock('./cardViews', async (importOriginal) => {
const actual = await importOriginal<typeof import('./cardViews')>();
const { createElement } = await import('react');
const views = {
...actual.CARD_VIEWS,
tourList: {
Block: function Block({ cards }: { cards: readonly { toolCallId: string }[] }) {
return createElement('p', null, `${cards.length} tour lists`);
},
},
tourRoute: {
Block: function Broken(): never {
throw new Error('broken card');
},
},
};
return { ...actual, CARD_VIEWS: views, viewOf: (card: TrekCard) => views[card.kind] };
});
const MESSAGE: AssistantMessage = {
id: 'm1',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-22T08:00:00.000Z',
parts: [],
};
const block = (card: TrekCard, count = 1): CardsBlock => ({
kind: 'cards',
key: 'cards-r1',
group: card.kind,
entries: Array.from({ length: count }, (_, i) => ({ toolCallId: `r${i}`, card })),
});
afterEach(() => vi.restoreAllMocks());
describe('AssistantCardBlock', () => {
it('FE-AI-CARDBLOCK-001: the kind draws the whole block', () => {
render(<AssistantCardBlock block={block({ kind: 'tourList' }, 2)} message={MESSAGE} />);
expect(screen.getByText('2 tour lists')).toBeInTheDocument();
});
it('FE-AI-CARDBLOCK-002: a kind not drawn yet shows nothing', () => {
const { container } = render(<AssistantCardBlock block={block({ kind: 'vacay' })} message={MESSAGE} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-CARDBLOCK-003: a card that throws is logged and left out', () => {
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
const { container } = render(
<div>
<AssistantCardBlock block={block({ kind: 'tourRoute' })} message={MESSAGE} />
<span>the answer</span>
</div>
);
expect(screen.getByText('the answer')).toBeInTheDocument();
expect(container.querySelector('p')).toBeNull();
expect(logged).toHaveBeenCalled();
});
});
@@ -0,0 +1,21 @@
import type { AssistantMessage } from '@trek/shared';
import type { CardsBlock } from '../assistantParts';
import { viewOf } from './cardViews';
import CardBoundary from './parts/CardBoundary';
/**
* The answer cards of one group, drawn by their kind's `Block`. A kind with
* no view yet draws nothing, and a card that throws is left out with the rest
* of the answer still in place.
*/
export default function AssistantCardBlock({ block, message }: { block: CardsBlock; message: AssistantMessage }) {
const first = block.entries[0];
const Block = first ? viewOf(first.card).Block : undefined;
if (!Block) return null;
return (
<CardBoundary>
<Block cards={block.entries} message={message} />
</CardBoundary>
);
}
@@ -0,0 +1,187 @@
/**
* FE-AI-SLOTS-001 to FE-AI-SLOTS-006
*
* A change that carries a card is drawn through its kind's view: the slots
* of a waiting row and of its receipt, a kind that draws its own subject, a
* group around a run of rows of one kind, a run of waiting rows decided as
* one, rows that start switched off, and slots that throw drawing what the
* row shows without a card. The views here are test views.
*/
import type { AssistantApplied, AssistantChangePreview, TrekCard } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../../store/assistantStore';
import AssistantChangeCard from '../AssistantChangeCard';
import type { ChangeItem } from '../assistantParts';
import { type AssistantView, AssistantViewContext } from '../assistantViewContext';
vi.mock('./cardViews', async (importOriginal) => {
const actual = await importOriginal<typeof import('./cardViews')>();
const { createElement } = await import('react');
const words = (label: string) =>
function Words() {
return createElement('span', null, label);
};
const broken = function Broken(): never {
throw new Error('broken view');
};
const views = {
...actual.CARD_VIEWS,
tourRoute: {
Lead: words('route lead'),
Aside: words('route aside'),
Facts: words('route facts'),
Body: words('route body'),
},
tourList: {
ownsSubject: true,
Group: function Group({ items, place }: { items: readonly ChangeItem[]; place: string }) {
return createElement('p', null, `${place} of ${items.length}`);
},
Run: function Run({
items,
excluded,
onToggle,
}: {
items: readonly ChangeItem[];
excluded: ReadonlySet<string>;
onToggle: (id: string) => void;
}) {
return createElement(
'button',
{ type: 'button', onClick: () => onToggle(items[0]?.toolCallId ?? '') },
`run of ${items.length}, ${excluded.size} off`
);
},
runnable: (card: TrekCard) => card.kind === 'tourList',
startsExcluded: () => true,
},
dayRoute: { Lead: broken, Aside: broken, Facts: broken, Body: broken, Run: broken },
};
return { ...actual, CARD_VIEWS: views, viewOf: (card: TrekCard) => views[card.kind] };
});
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const ROUTE: TrekCard = { kind: 'tourRoute' };
const LIST: TrekCard = { kind: 'tourList' };
const BROKEN: TrekCard = { kind: 'dayRoute' };
function preview(toolCallId: string, subject: string): AssistantChangePreview {
return {
toolCallId,
verbKey: 'addTour',
subject,
rows: [{ op: 'change', labelKey: 'time', before: '09:00', after: '10:30' }],
dayId: 3,
placeIds: [],
undoable: true,
};
}
const pending = (id: string, subject: string, card: TrekCard | null): ChangeItem => ({
toolCallId: id,
preview: preview(id, subject),
applied: null,
state: 'pending',
card,
});
function receipt(id: string, card: TrekCard): ChangeItem {
const applied: AssistantApplied = { toolCallId: id, entity: 'place', ids: [9], dayId: 3, undoUntil: null };
return { ...pending(id, 'Ridge walk', card), applied, state: 'applied' };
}
function renderCard(items: ChangeItem[], view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', ...view };
return render(
<AssistantViewContext.Provider value={value}>
<AssistantChangeCard items={items} />
</AssistantViewContext.Provider>
);
}
const decide = vi.fn(async () => {});
beforeEach(() => {
useAssistantStore.setState({
decide,
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 1 } },
});
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
vi.clearAllMocks();
});
describe('ChangeCardSlots', () => {
it('FE-AI-SLOTS-001: a waiting change with a card fills its row from the kind, in place of the verb tile and fields', () => {
renderCard([pending('c1', 'Ridge walk', ROUTE)]);
for (const slot of ['route lead', 'route aside', 'route facts', 'route body']) {
expect(screen.getByText(slot)).toBeInTheDocument();
}
expect(screen.getByText('Ridge walk')).toBeInTheDocument();
expect(screen.queryByText('10:30')).toBeNull();
});
it('FE-AI-SLOTS-002: a receipt keeps the slots, with the way to what it changed', () => {
const onOpen = vi.fn();
renderCard([receipt('c1', ROUTE)], { onOpen });
for (const slot of ['route lead', 'route aside', 'route facts', 'route body']) {
expect(screen.getByText(slot)).toBeInTheDocument();
}
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 9, dayId: null });
});
it('FE-AI-SLOTS-003: a kind that draws its subject hides it, and a group frames its run of rows', () => {
renderCard([receipt('c1', LIST), receipt('c2', LIST), pending('c3', 'Plain change', null)]);
expect(screen.queryByText('Ridge walk')).toBeNull();
expect(screen.getByText('head of 2')).toBeInTheDocument();
expect(screen.getByText('foot of 2')).toBeInTheDocument();
expect(screen.getByText('Plain change')).toBeInTheDocument();
});
it('FE-AI-SLOTS-004: two or more waiting rows the kind can take together are one run; a lone one stays a row', () => {
const first = renderCard([pending('c1', 'One', LIST), pending('c2', 'Two', LIST)]);
expect(screen.getByRole('button', { name: /^run of 2/ })).toBeInTheDocument();
first.unmount();
renderCard([pending('c1', 'One', LIST), pending('c2', 'Two', ROUTE)]);
expect(screen.queryByRole('button', { name: /^run of/ })).toBeNull();
expect(screen.getAllByRole('button', { name: 'Include this change' })).toHaveLength(2);
});
it('FE-AI-SLOTS-005: rows the kind starts off are left off once, until the user switches them on', () => {
const items = [pending('c1', 'One', LIST), pending('c2', 'Two', LIST)];
const view = renderCard(items);
expect(screen.getByRole('button', { name: 'run of 2, 2 off' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Apply 0' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'run of 2, 2 off' }));
view.rerender(
<AssistantViewContext.Provider value={{ tripId: TRIP, variant: 'window' }}>
<AssistantChangeCard items={[...items]} />
</AssistantViewContext.Provider>
);
expect(screen.getByRole('button', { name: 'run of 2, 1 off' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Apply 1' }));
expect(decide).toHaveBeenCalledWith(TRIP, [
{ toolCallId: 'c1', approved: true },
{ toolCallId: 'c2', approved: false },
]);
});
it('FE-AI-SLOTS-006: slots that throw draw the plain row: a run its rows, the facts the fields', () => {
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
renderCard([pending('c1', 'One', BROKEN), pending('c2', 'Two', BROKEN)]);
expect(screen.getByText('One')).toBeInTheDocument();
expect(screen.getByText('Two')).toBeInTheDocument();
expect(screen.getAllByText('10:30')).toHaveLength(2);
expect(screen.getAllByRole('button', { name: 'Include this change' })).toHaveLength(2);
expect(logged).toHaveBeenCalled();
logged.mockRestore();
});
});
@@ -0,0 +1,357 @@
import type { AssistantChangePreview, TrekCardKind } from '@trek/shared';
import { Check, Undo2 } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { Tooltip } from '../../shared/Tooltip';
import AssistantChangeFields from '../AssistantChangeFields';
import type { ChangeItem } from '../assistantParts';
import { useAssistantView } from '../assistantViewContext';
import { useChangeHover } from '../useChangeHover';
import { TOOL_LABELS, verbLookOf } from './cardLooks';
import type { CardView } from './cardTypes';
import { viewOf } from './cardViews';
import EntityActions from './EntityActions';
import { ICON_BUTTON, RECEIPT } from './parts/assistantTones';
import CardBoundary from './parts/CardBoundary';
import KindTile from './parts/KindTile';
/**
* The rows of the change card: a change waiting for its decision, and the
* receipt it turns into. A change that carries a card is drawn through its
* kind's view, slot by slot (`Lead` in front, `Aside` at the end of the
* subject line, `Facts` on the faint line, `Body` under it); a change
* without one reads as it always has: the tile of its verb, its subject, and
* the few fields that tell it apart. A slot that throws draws what the row
* shows without a card, so one broken view never takes the chat down.
*/
type View = CardView<TrekCardKind>;
/** What the change does: the tool's own action where TREK names it, else add, change or remove. */
function verbLabel(preview: AssistantChangePreview, t: (key: string) => string): string {
// Read as a string: a tool named like an object's own member (toString) has no label.
const own = preview.tool ? TOOL_LABELS[preview.tool]?.labelKey : undefined;
return t(typeof own === 'string' ? own : verbLookOf(preview.verbKey).labelKey);
}
/** The tile in front: the kind's own lead, else the verb's tile. */
function Lead({ item, view, dimmed }: { item: ChangeItem; view: View | null; dimmed: boolean }) {
const look = item.preview ? verbLookOf(item.preview.verbKey) : null;
const tile = <KindTile icon={look?.Icon ?? null} tone={look?.tone ?? 'info'} size="sm" dimmed={dimmed} />;
const Own = view?.Lead;
if (!Own || !item.card) return tile;
return (
<CardBoundary fallback={tile}>
<Own card={item.card} item={item} />
</CardBoundary>
);
}
/** A slot of the kind's view, or nothing; `fallback` where it throws. */
function Slot({
item,
view,
name,
fallback = null,
}: {
item: ChangeItem;
view: View | null;
name: 'Aside' | 'Facts' | 'Body';
fallback?: ReactNode;
}) {
const Own = view?.[name];
if (!Own || !item.card) return null;
return (
<CardBoundary fallback={fallback}>
<Own card={item.card} item={item} />
</CardBoundary>
);
}
/** The subject in bold, with the kind's aside at its end; only the aside where the kind draws the subject itself. */
function SubjectLine({
subject,
struck,
aside,
block = false,
}: {
subject: string;
struck: boolean;
aside: ReactNode;
block?: boolean;
}) {
const text = (
<bdi
className={`${block ? 'block ' : ''}truncate text-body font-semibold leading-tight text-content ${struck ? 'line-through' : ''}`}
>
{subject}
</bdi>
);
if (!aside) return text;
return (
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate">{text}</span>
{aside}
</span>
);
}
/** Where in TREK a change outside the trip plan lands: the vacation planner, the journal. */
function DomainChip({ domain }: { domain: string | undefined }) {
const { t } = useTranslation();
if (!domain) return null;
return (
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 text-content-secondary">
{t(`assistant.domain.${domain}`)}
</span>
);
}
function ChangeHead({ item, view, struck }: { item: ChangeItem; view: View | null; struck: boolean }) {
const { t } = useTranslation();
const preview = item.preview;
if (!preview) return <span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />;
const aside = <Slot item={item} view={view} name="Aside" />;
return (
<span className="flex min-w-0 flex-col">
{view?.ownsSubject ? (
aside
) : (
<SubjectLine subject={preview.subject} struck={struck} aside={view?.Aside && aside} />
)}
<span className="mt-0.5 flex min-w-0 items-center gap-2.5 text-caption text-content-faint">
<span className="truncate">{verbLabel(preview, t)}</span>
<DomainChip domain={preview.domain} />
<Slot item={item} view={view} name="Facts" />
</span>
</span>
);
}
/** The kind's body under a row, faded with the row once the change is settled. */
function BodySlot({ item, view, settled }: { item: ChangeItem; view: View | null; settled: boolean }) {
if (!view?.Body || !item.card) return null;
return (
<div className={`ps-[38px] pb-1.5 ${settled ? 'opacity-60' : ''}`}>
<Slot item={item} view={view} name="Body" />
</div>
);
}
/** A decided change: its tile and head, the ways to what it changed, undo while it can, and its mark. */
function Receipt({
item,
undone,
canUndo,
onUndo,
}: {
item: ChangeItem;
undone: boolean;
canUndo: boolean;
onUndo: () => void;
}) {
const { t } = useTranslation();
if (item.state === 'pending') return null;
const receipt = undone ? RECEIPT.discarded : RECEIPT[item.state];
const { Icon } = receipt;
const applied = item.state === 'applied' && !undone ? item.applied : null;
const settled = item.state === 'discarded' || item.state === 'expired' || undone;
const statusLabel = t(undone ? 'assistant.change.undone' : receipt.labelKey);
const view = item.card ? viewOf(item.card) : null;
return (
<>
<div className="trek-assistant-enter flex items-center gap-2.5 py-1.5">
<Lead item={item} view={view} dimmed={settled} />
<div className={`min-w-0 flex-1 ${settled ? 'opacity-60' : ''}`}>
<ChangeHead item={item} view={view} struck={settled} />
</div>
{applied && <EntityActions applied={applied} card={item.card} />}
{canUndo && (
<Tooltip label={t('assistant.change.undo')} placement="top">
<button type="button" onClick={onUndo} aria-label={t('assistant.change.undo')} className={ICON_BUTTON}>
<Undo2 size={14} aria-hidden="true" />
</button>
</Tooltip>
)}
<Tooltip label={statusLabel} placement="top">
<span
role="img"
aria-label={statusLabel}
className={`flex h-7 w-5 items-center justify-center ${receipt.tone}`}
>
<Icon size={15} strokeWidth={2.4} className={item.state === 'applying' ? 'animate-spin' : ''} />
</span>
</Tooltip>
</div>
<BodySlot item={item} view={view} settled={settled} />
</>
);
}
/**
* A change waiting for its decision, as a row of the planner's day list: the
* round choice in front (only when there is more than one to choose from),
* what it touches in bold, and a faint line saying what happens and when.
* Resting on it lights its pin on the map.
*/
function PendingRow({
item,
excluded,
withChoice,
disabled,
onToggle,
}: {
item: ChangeItem;
excluded: boolean;
withChoice: boolean;
disabled: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const { litFromMap, handlers } = useChangeHover(tripId, item.preview?.effect ? item.toolCallId : null);
const preview = item.preview;
const view = item.card ? viewOf(item.card) : null;
const aside = <Slot item={item} view={view} name="Aside" />;
return (
<>
<div
{...handlers}
className={`-mx-1.5 flex items-center gap-2.5 rounded-[12px] px-1.5 py-2 transition-[opacity,background-color] ${
excluded ? 'opacity-50' : ''
} ${litFromMap ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`}
>
{withChoice && (
<button
type="button"
onClick={onToggle}
disabled={disabled}
aria-pressed={!excluded}
aria-label={t('assistant.change.include')}
className={`grid h-[18px] w-[18px] flex-none place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 ${
excluded ? 'border-[1.5px] border-edge bg-surface-card' : 'bg-accent'
}`}
>
{!excluded && <Check size={11} strokeWidth={3} aria-hidden="true" className="text-accent-text" />}
</button>
)}
<Lead item={item} view={view} dimmed={excluded} />
<div className="min-w-0 flex-1">
{preview ? (
<>
{view?.ownsSubject ? (
aside
) : (
<SubjectLine subject={preview.subject} struck={false} aside={view?.Aside && aside} block />
)}
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-caption text-content-faint">
<span className="me-0.5 min-w-0 truncate">{verbLabel(preview, t)}</span>
<DomainChip domain={preview.domain} />
{view?.Facts ? (
<Slot item={item} view={view} name="Facts" fallback={<AssistantChangeFields rows={preview.rows} />} />
) : (
<AssistantChangeFields rows={preview.rows} />
)}
</span>
</>
) : (
<span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />
)}
</div>
</div>
<BodySlot item={item} view={view} settled={excluded} />
</>
);
}
interface RowsProps {
items: readonly ChangeItem[];
/** The waiting rows wait behind the week grid. */
folded: boolean;
excluded: ReadonlySet<string>;
disabled: boolean;
undone: Readonly<Record<string, true>>;
undoable: readonly string[];
onToggle: (toolCallId: string) => void;
onUndo: (toolCallId: string) => void;
}
/** Consecutive changes of one kind of card; a change without a card stands alone. */
function runsOf(items: readonly ChangeItem[]): ChangeItem[][] {
const runs: ChangeItem[][] = [];
for (const item of items) {
const last = runs[runs.length - 1];
const kind = item.card?.kind;
if (last && kind && last[0]?.card?.kind === kind) last.push(item);
else runs.push([item]);
}
return runs;
}
/** Whether a row can join a `Run`: it waits, and its view takes it. */
function runnable(item: ChangeItem, view: View | null): boolean {
if (item.state !== 'pending' || !item.card || !view?.Run) return false;
return view.runnable ? view.runnable(item.card) : true;
}
/**
* Every row of the card in order. A run of rows of one kind gets the kind's
* `Group` before and after it, and two or more waiting rows its view can
* take together are drawn as one `Run` instead.
*/
export function ChangeRows({ items, folded, excluded, disabled, undone, undoable, onToggle, onUndo }: RowsProps) {
const pendingCount = items.filter((i) => i.state === 'pending').length;
const row = (item: ChangeItem) =>
item.state === 'pending' ? (
folded ? null : (
<PendingRow
key={item.toolCallId}
item={item}
excluded={excluded.has(item.toolCallId)}
withChoice={pendingCount > 1}
disabled={disabled}
onToggle={() => onToggle(item.toolCallId)}
/>
)
) : (
<Receipt
key={item.toolCallId}
item={item}
undone={!!undone[item.toolCallId]}
canUndo={undoable.includes(item.toolCallId)}
onUndo={() => onUndo(item.toolCallId)}
/>
);
const rows = runsOf(items).flatMap((run) => {
const first = run[0];
const view = first?.card ? viewOf(first.card) : null;
const Group = view?.Group;
const Run = view?.Run;
const body: ReactNode[] = [];
for (let i = 0; i < run.length; i++) {
let end = i;
while (end < run.length && runnable(run[end] as ChangeItem, view)) end++;
if (Run && end - i >= 2) {
const taken = run.slice(i, end);
if (!folded) {
// A run that throws falls back to its rows one by one.
body.push(
<CardBoundary key={`run-${taken[0]?.toolCallId}`} fallback={taken.map(row)}>
<Run items={taken} excluded={excluded} onToggle={onToggle} />
</CardBoundary>
);
}
i = end - 1;
} else body.push(row(run[i] as ChangeItem));
}
if (!Group || !first) return body;
const group = (place: 'head' | 'foot') => (
<CardBoundary key={`${place}-${first.toolCallId}`}>
<Group items={run} excluded={excluded} onToggle={onToggle} place={place} />
</CardBoundary>
);
return [group('head'), ...body, group('foot')];
});
return <div>{rows}</div>;
}
@@ -0,0 +1,90 @@
/**
* FE-AI-ENTITY-001 to FE-AI-ENTITY-004
*
* What a receipt offers for the thing it changed, read off the entity's
* look: the map pin where the plan can show it, "Open" where it lives in
* another part of TREK, and nothing where the shell cannot open anything.
*/
import type { AssistantApplied } 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 EntityActions from './EntityActions';
vi.mock('./cardLooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('./cardLooks')>();
const looks = {
...actual.ENTITY_LOOK,
tour: {
...actual.ENTITY_LOOK.tour,
openLabelKey: 'assistant.change.viewOnMap',
phoneOpenLabelKey: 'assistant.history.open',
open: (applied: AssistantApplied) => ({
kind: 'tab',
tab: 'tour-planner',
params: { tour: String(applied.ids[0]) },
}),
},
budget: { ...actual.ENTITY_LOOK.budget, open: () => ({ kind: 'tab', tab: 'finanzplan' }) },
};
return { ...actual, ENTITY_LOOK: looks, entityLookOf: (entity: AssistantApplied['entity']) => looks[entity] };
});
const applied = (entity: AssistantApplied['entity'], ids: number[], dayId: number | null): AssistantApplied => ({
toolCallId: 'c1',
entity,
ids,
dayId,
undoUntil: null,
});
function renderActions(change: AssistantApplied, view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
return render(
<AssistantViewContext.Provider value={value}>
<EntityActions applied={change} card={null} />
</AssistantViewContext.Provider>
);
}
describe('EntityActions', () => {
it('FE-AI-ENTITY-001: a place shows itself on the map, a stop its day', () => {
const onOpen = vi.fn();
const first = renderActions(applied('place', [7], 2), { onOpen });
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'map', placeId: 7, dayId: null });
first.unmount();
renderActions(applied('assignment', [41], 3), { onOpen });
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }));
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'map', placeId: null, dayId: 3 });
});
it('FE-AI-ENTITY-002: nothing shows where the shell cannot open anything', () => {
const { container } = renderActions(applied('place', [7], 2));
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-ENTITY-003: what has no place on the map gets no pin', () => {
const { container } = renderActions(applied('packing', [2], null), { onOpen: vi.fn() });
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-ENTITY-004: "Open" goes where the entity lives, in its own words on the desktop and the phone', () => {
const onOpen = vi.fn();
const desktop = renderActions(applied('tour', [12], null), { onOpen });
expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'View on map' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'tour-planner', params: { tour: '12' } });
desktop.unmount();
const phone = renderActions(applied('tour', [12], null), { onOpen, variant: 'fullscreen' });
expect(screen.getByRole('button', { name: 'Recent chats' })).toBeInTheDocument();
phone.unmount();
renderActions(applied('budget', [3], null), { onOpen });
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'finanzplan' });
});
});
@@ -0,0 +1,49 @@
import type { AssistantApplied, TrekCard } from '@trek/shared';
import { ArrowUpRight, MapPin } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { Tooltip } from '../../shared/Tooltip';
import { showOnMapTarget } from '../assistantShowOnMap';
import { useAssistantView } from '../assistantViewContext';
import { entityLookOf } from './cardLooks';
import { ICON_BUTTON } from './parts/assistantTones';
/**
* What a receipt offers for the thing it changed, as its entity's look says:
* the map pin where the plan's map can show it, and "Open" where it lives
* somewhere else in TREK (a tab of the trip, another page). Neither shows
* where the shell around the chat cannot open anything.
*/
export default function EntityActions({ applied, card }: { applied: AssistantApplied; card: TrekCard | null }) {
const { t } = useTranslation();
const { variant, onOpen } = useAssistantView();
if (!onOpen) return null;
const look = entityLookOf(applied.entity);
const onMap = showOnMapTarget(applied, card);
const open = look.open?.(applied, card) ?? null;
const openKey = variant === 'fullscreen' ? (look.phoneOpenLabelKey ?? look.openLabelKey) : look.openLabelKey;
const openLabel = openKey ? t(openKey) : t('assistant.card.common.open');
return (
<>
{onMap && (
<Tooltip label={t('assistant.change.showOnMap')} placement="top">
<button
type="button"
onClick={() => onOpen({ kind: 'map', ...onMap })}
aria-label={t('assistant.change.showOnMap')}
className={ICON_BUTTON}
>
<MapPin size={14} aria-hidden="true" />
</button>
</Tooltip>
)}
{open && (
<Tooltip label={openLabel} placement="top">
<button type="button" onClick={() => onOpen(open)} aria-label={openLabel} className={ICON_BUTTON}>
<ArrowUpRight size={14} aria-hidden="true" className="rtl:-scale-x-100" />
</button>
</Tooltip>
)}
</>
);
}
@@ -0,0 +1,20 @@
import type { PackLook } from '../cardTypes';
/**
* What the atlas brings to the chat's pure look: the verbs, tool names and
* steps it adds, what an applied change of its own opens, and the look of each
* of its kinds. Empty until the atlas cards fill it. A look stays pure, icons
* and literals only: the chat's main chunk reads every look, so whatever a look
* imports ships with it.
*/
export const ATLAS_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {},
cards: {
atlasPassport: {},
atlasCountry: {},
atlasBucket: {},
},
} satisfies PackLook<'atlasPassport' | 'atlasCountry' | 'atlasBucket'>;
@@ -0,0 +1,14 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
/**
* How the kinds of the atlas draw in the chat; each one draws nothing until its
* cards are built. This file only maps each kind to its view: the components
* live in sibling files, since a file that exports anything besides components
* breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const ATLAS_VIEWS = {
atlasPassport: PENDING_VIEW,
atlasCountry: PENDING_VIEW,
atlasBucket: PENDING_VIEW,
} satisfies { [K in 'atlasPassport' | 'atlasCountry' | 'atlasBucket']: CardView<K> };
@@ -0,0 +1,23 @@
import { Ticket } from 'lucide-react';
import type { PackLook } from '../cardTypes';
/**
* What the bookings bring to the chat's pure look: the verbs, tool names and
* steps they add, what an applied change of theirs opens, and the look of each
* of their kinds. Empty but for the booking entity until the booking cards fill
* it. A look stays pure, icons and literals only: the chat's main chunk reads
* every look, so whatever a look imports ships with it.
*/
export const BOOKING_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {
// An applied booking shows on the map by its day, as before.
reservation: { Icon: Ticket, onMap: (applied) => ({ placeId: null, dayId: applied.dayId }) },
},
cards: {
booking: {},
},
} satisfies PackLook<'booking'>;
@@ -0,0 +1,12 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
/**
* How the kinds of the bookings draw in the chat; each one draws nothing until
* its cards are built. This file only maps each kind to its view: the
* components live in sibling files, since a file that exports anything besides
* components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const BOOKING_VIEWS = {
booking: PENDING_VIEW,
} satisfies { [K in 'booking']: CardView<K> };
@@ -0,0 +1,25 @@
import { Wallet } from 'lucide-react';
import type { PackLook } from '../cardTypes';
/**
* What the costs bring to the chat's pure look: the verbs, tool names and steps
* they add, what an applied change of theirs opens, and the look of each of
* their kinds. An applied expense has no way to open yet, as before. A look
* stays pure, icons and literals only: the chat's main chunk reads every look,
* so whatever a look imports ships with it.
*/
export const BUDGET_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {
budget: { Icon: Wallet },
},
cards: {
expenses: {},
payments: {},
settlement: {},
costPerPerson: {},
},
} satisfies PackLook<'expenses' | 'payments' | 'settlement' | 'costPerPerson'>;
@@ -0,0 +1,15 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
/**
* How the kinds of the costs draw in the chat; each one draws nothing until its
* cards are built. This file only maps each kind to its view: the components
* live in sibling files, since a file that exports anything besides components
* breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const BUDGET_VIEWS = {
expenses: PENDING_VIEW,
payments: PENDING_VIEW,
settlement: PENDING_VIEW,
costPerPerson: PENDING_VIEW,
} satisfies { [K in 'expenses' | 'payments' | 'settlement' | 'costPerPerson']: CardView<K> };
@@ -0,0 +1,102 @@
/**
* FE-AI-LOOK-001 to FE-AI-LOOK-007
*
* The chat's merged look: every verb, tool and kind has its row with a
* literal key, applied changes open what they opened before the cards came,
* every kind has a view, the shapes the map hooks of a kind fill, the
* layout and map sides read through each kind's look, and a verb or entity
* this build does not know looks like its generic one.
*/
import {
ASSISTANT_CHANGE_VERB_KEYS,
type AssistantApplied,
type AssistantChangeVerbKey,
type TrekCardKind,
trekCardSchema,
} from '@trek/shared';
import { describe, expect, it } from 'vitest';
import type { GhostPoint, RouteLineSpec, RouteUse } from '../../../types/assistantMap';
import {
CARD_LOOKS,
CARD_MAP_LOOK,
CARD_RULES,
ENTITY_LOOK,
STEP_LOOKS,
TOOL_LABELS,
VERB_LOOK,
entityLookOf,
verbLookOf,
} from './cardLooks';
import type { CardLook } from './cardTypes';
import { CARD_VIEWS } from './cardViews';
const KINDS = trekCardSchema.options.map((option) => option.shape.kind.value) as TrekCardKind[];
describe('cardLooks', () => {
it('FE-AI-LOOK-001: every verb is named by its own key', () => {
for (const verb of ASSISTANT_CHANGE_VERB_KEYS) {
expect(VERB_LOOK[verb].labelKey).toBe(`assistant.change.verb.${verb}`);
}
});
it('FE-AI-LOOK-002: every tool label and step is a key of its own namespace', () => {
for (const [tool, look] of Object.entries(TOOL_LABELS)) expect(look?.labelKey).toBe(`assistant.tool.${tool}`);
for (const look of Object.values(STEP_LOOKS)) expect(look?.stepKey).toMatch(/^assistant\.step\./);
expect(STEP_LOOKS.get_trip_summary?.stepKey).toBe('assistant.step.reading');
});
it('FE-AI-LOOK-003: only a place, a stop, a note and a booking show on the map, as before the cards', () => {
const onMap = Object.entries(ENTITY_LOOK).flatMap(([entity, look]) => (look.onMap ? [entity] : []));
expect(onMap.sort()).toEqual(['assignment', 'day_note', 'place', 'reservation']);
expect(Object.values(ENTITY_LOOK).every((look) => !look.open)).toBe(true);
});
it('FE-AI-LOOK-004: every kind has a look and a view; a pending view groups by kind and gives way to nothing', () => {
expect(Object.keys(CARD_LOOKS).sort()).toEqual([...KINDS].sort());
expect(Object.keys(CARD_VIEWS).sort()).toEqual([...KINDS].sort());
expect(CARD_RULES.group({ kind: 'vacay' })).toBe('vacay');
expect(CARD_RULES.yieldsToChange({ kind: 'expenses' }, { kind: 'payments' })).toBe(false);
});
it('FE-AI-LOOK-005: a kind draws its lines and points on the map through its look', () => {
const look: CardLook<'dayRoute'> = {
lines: (_card, use: RouteUse): RouteLineSpec[] => [
{ id: `day:${use}`, lines: [[[45.1, 7.2]]], color: 'var(--accent)', dashed: use !== 'pending' },
],
ghosts: (): GhostPoint[] => [{ id: 'via', lat: 45.1, lng: 7.2, name: 'Col', variant: 'point' }],
};
expect(look.lines?.({ kind: 'dayRoute' }, 'before')).toEqual([
{ id: 'day:before', lines: [[[45.1, 7.2]]], color: 'var(--accent)', dashed: true },
]);
expect(look.ghosts?.({ kind: 'dayRoute' })[0]?.variant).toBe('point');
});
it("FE-AI-LOOK-006: the layout rules and the map read a kind's look, nothing for one that says nothing", () => {
const route = CARD_LOOKS.dayRoute;
route.group = () => 'routes';
route.yieldsToChange = (_answer, change) => change.kind === 'dayRoute';
route.lines = (_card, use) => [{ id: `day:${use}`, lines: [], color: 'var(--accent)' }];
try {
expect(CARD_RULES.group({ kind: 'dayRoute' })).toBe('routes');
expect(CARD_RULES.yieldsToChange({ kind: 'dayRoute' }, { kind: 'dayRoute' })).toBe(true);
expect(CARD_MAP_LOOK.lines({ kind: 'dayRoute' }, 'look')).toEqual([
{ id: 'day:look', lines: [], color: 'var(--accent)' },
]);
} finally {
delete route.group;
delete route.yieldsToChange;
delete route.lines;
}
expect(CARD_MAP_LOOK.lines({ kind: 'dayRoute' }, 'look')).toEqual([]);
expect(CARD_MAP_LOOK.ghosts({ kind: 'vacay' })).toEqual([]);
expect(CARD_MAP_LOOK.hidesEffectGhost({ kind: 'vacay' })).toBe(false);
});
it('FE-AI-LOOK-007: a verb or entity only a newer server knows looks like a plain update or an other', () => {
expect(verbLookOf('laterVerb' as AssistantChangeVerbKey)).toBe(VERB_LOOK.genericUpdate);
expect(entityLookOf('later_entity' as AssistantApplied['entity'])).toBe(ENTITY_LOOK.other);
expect(verbLookOf('addPlace')).toBe(VERB_LOOK.addPlace);
expect(entityLookOf('place')).toBe(ENTITY_LOOK.place);
});
});
@@ -0,0 +1,248 @@
import type { AssistantApplied, AssistantChangeVerbKey, TrekCard, TrekCardKind } from '@trek/shared';
import {
CalendarArrowUp,
CalendarDays,
CalendarMinus,
CalendarPlus,
Circle,
ListTodo,
Luggage,
MapPin,
MapPinPlus,
NotebookPen,
PencilLine,
Plus,
StickyNote,
Ticket,
Trash2,
Wallet,
} from 'lucide-react';
import type { CardMapLook } from '../../../types/assistantMap';
import type { CardRules } from '../assistantParts';
import { ATLAS_LOOK } from './atlas/atlasLook';
import { BOOKING_LOOK } from './booking/bookingLook';
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 { JOURNEY_LOOK } from './journey/journeyLook';
import { PACKING_LOOK } from './packing/packingLook';
import { ROADTRIP_LOOK } from './roadtrip/roadtripLook';
import { TOUR_LOOK } from './tour/tourLook';
import { VACAY_LOOK } from './vacay/vacayLook';
/**
* The chat's whole pure look: the core rows (the trip plan's verbs, the tools
* TREK names an action for, the reading steps, what an applied change opens)
* with every part of TREK's own look merged in. No components here, so the
* step names, the share text and the map read it without pulling in a card.
*
* Every label is a literal key, so the i18n check sees each one where it is
* named instead of guessing at a template.
*/
/** What each kind of change looks like. A table, so a new verb fails to compile until it has a look. */
export const VERB_LOOK: Readonly<Record<AssistantChangeVerbKey, VerbLook>> = {
addPlace: { Icon: MapPinPlus, tone: 'success', labelKey: 'assistant.change.verb.addPlace' },
addPlaceToDay: { Icon: CalendarPlus, tone: 'success', labelKey: 'assistant.change.verb.addPlaceToDay' },
updatePlace: { Icon: PencilLine, tone: 'info', labelKey: 'assistant.change.verb.updatePlace' },
assignPlace: { Icon: CalendarPlus, tone: 'success', labelKey: 'assistant.change.verb.assignPlace' },
moveAssignment: { Icon: CalendarArrowUp, tone: 'warning', labelKey: 'assistant.change.verb.moveAssignment' },
removeFromDay: { Icon: CalendarMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeFromDay' },
addDayNote: { Icon: StickyNote, tone: 'success', labelKey: 'assistant.change.verb.addDayNote' },
updateDayNote: { Icon: StickyNote, tone: 'info', labelKey: 'assistant.change.verb.updateDayNote' },
addPackingItem: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.addPackingItem' },
updatePackingItem: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.updatePackingItem' },
addTodo: { Icon: ListTodo, tone: 'success', labelKey: 'assistant.change.verb.addTodo' },
addBudgetItem: { Icon: Wallet, tone: 'success', labelKey: 'assistant.change.verb.addBudgetItem' },
updateBudgetItem: { Icon: Wallet, tone: 'info', labelKey: 'assistant.change.verb.updateBudgetItem' },
addReservation: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.addReservation' },
importBooking: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.importBooking' },
rememberNote: { Icon: NotebookPen, tone: 'info', labelKey: 'assistant.change.verb.rememberNote' },
addTour: { Icon: MapPinPlus, tone: 'success', labelKey: 'assistant.change.verb.addTour' },
updateTour: { Icon: PencilLine, tone: 'info', labelKey: 'assistant.change.verb.updateTour' },
importTour: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.importTour' },
genericCreate: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.genericCreate' },
genericUpdate: { Icon: PencilLine, tone: 'info', labelKey: 'assistant.change.verb.genericUpdate' },
genericRemove: { Icon: Trash2, tone: 'danger', labelKey: 'assistant.change.verb.genericRemove' },
...TOUR_LOOK.verbs,
...ROADTRIP_LOOK.verbs,
...BUDGET_LOOK.verbs,
...BOOKING_LOOK.verbs,
...COLLAB_LOOK.verbs,
...COLLECTION_LOOK.verbs,
...ATLAS_LOOK.verbs,
...VACAY_LOOK.verbs,
...JOURNEY_LOOK.verbs,
...PACKING_LOOK.verbs,
};
/** The tools TREK names an action of their own for; a change by any other tool is named by its verb. */
export const TOOL_LABELS: Readonly<Partial<Record<string, { labelKey: string }>>> = {
reorder_day_assignments: { labelKey: 'assistant.tool.reorder_day_assignments' },
update_assignment_time: { labelKey: 'assistant.tool.update_assignment_time' },
update_assignment_notes: { labelKey: 'assistant.tool.update_assignment_notes' },
set_leg_transport_mode: { labelKey: 'assistant.tool.set_leg_transport_mode' },
set_assignment_end_day: { labelKey: 'assistant.tool.set_assignment_end_day' },
clear_day_assignments: { labelKey: 'assistant.tool.clear_day_assignments' },
set_assignment_participants: { labelKey: 'assistant.tool.set_assignment_participants' },
create_day: { labelKey: 'assistant.tool.create_day' },
update_day: { labelKey: 'assistant.tool.update_day' },
delete_day: { labelKey: 'assistant.tool.delete_day' },
reorder_days: { labelKey: 'assistant.tool.reorder_days' },
delete_place: { labelKey: 'assistant.tool.delete_place' },
rate_place: { labelKey: 'assistant.tool.rate_place' },
bulk_update_places: { labelKey: 'assistant.tool.bulk_update_places' },
delete_day_note: { labelKey: 'assistant.tool.delete_day_note' },
toggle_packing_item: { labelKey: 'assistant.tool.toggle_packing_item' },
delete_packing_item: { labelKey: 'assistant.tool.delete_packing_item' },
update_todo: { labelKey: 'assistant.tool.update_todo' },
toggle_todo: { labelKey: 'assistant.tool.toggle_todo' },
delete_todo: { labelKey: 'assistant.tool.delete_todo' },
delete_budget_item: { labelKey: 'assistant.tool.delete_budget_item' },
update_reservation: { labelKey: 'assistant.tool.update_reservation' },
delete_reservation: { labelKey: 'assistant.tool.delete_reservation' },
create_accommodation: { labelKey: 'assistant.tool.create_accommodation' },
create_transport: { labelKey: 'assistant.tool.create_transport' },
toggle_vacay_entry: { labelKey: 'assistant.tool.toggle_vacay_entry' },
mark_country_visited: { labelKey: 'assistant.tool.mark_country_visited' },
confirm_bucket_list_visits: { labelKey: 'assistant.tool.confirm_bucket_list_visits' },
clear_bucket_list_visit: { labelKey: 'assistant.tool.clear_bucket_list_visit' },
accept_atlas_countries: { labelKey: 'assistant.tool.accept_atlas_countries' },
send_collab_message: { labelKey: 'assistant.tool.send_collab_message' },
...TOUR_LOOK.tools,
...ROADTRIP_LOOK.tools,
...BUDGET_LOOK.tools,
...BOOKING_LOOK.tools,
...COLLAB_LOOK.tools,
...COLLECTION_LOOK.tools,
...ATLAS_LOOK.tools,
...VACAY_LOOK.tools,
...JOURNEY_LOOK.tools,
...PACKING_LOOK.tools,
};
/** 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' },
list_places: { stepKey: 'assistant.step.reading' },
search_place: {
stepKey: 'assistant.step.searching',
live: { liveKey: 'assistant.step.searchingFor', param: 'query' },
},
search_pois: { stepKey: 'assistant.step.searching', live: { liveKey: 'assistant.step.nearbyAt', param: 'place' } },
get_place_details: { stepKey: 'assistant.step.details' },
search_nearby_places: {
stepKey: 'assistant.step.nearby',
live: { liveKey: 'assistant.step.nearbyAt', param: 'place' },
},
list_categories: { stepKey: 'assistant.step.categories' },
get_weather: { stepKey: 'assistant.step.weather', live: { liveKey: 'assistant.step.weatherAt', param: 'place' } },
list_todos: { stepKey: 'assistant.step.todos' },
get_settlement_summary: { stepKey: 'assistant.step.costs' },
list_budget_items: { stepKey: 'assistant.step.expenses' },
get_budget_per_person: { stepKey: 'assistant.step.shares' },
get_wind_forecast: { stepKey: 'assistant.step.wind' },
get_journey_tracks: { stepKey: 'assistant.step.tracks' },
scan_bucket_list_visits: { stepKey: 'assistant.step.bucketScan' },
suggest_atlas_countries: { stepKey: 'assistant.step.atlasCountries' },
propose_hike: {
stepKey: 'assistant.step.planningHike',
live: { liveKey: 'assistant.step.planningHikeTo', param: 'place' },
},
search_help: { stepKey: 'assistant.step.help' },
read_booking_file: { stepKey: 'assistant.step.readingBooking' },
...TOUR_LOOK.steps,
...ROADTRIP_LOOK.steps,
...BUDGET_LOOK.steps,
...BOOKING_LOOK.steps,
...COLLAB_LOOK.steps,
...COLLECTION_LOOK.steps,
...ATLAS_LOOK.steps,
...VACAY_LOOK.steps,
...JOURNEY_LOOK.steps,
...PACKING_LOOK.steps,
};
/** Shown on the map by its day: a stop or a note lives on one. */
const BY_DAY: EntityLook['onMap'] = (applied) => ({ placeId: null, dayId: applied.dayId });
/**
* What an applied change touches and where it can be seen. A place shows
* itself, a stop or a note its day; a kept note and the rest have no place on
* the map. A table, so a new entity fails to compile until it has a row.
*/
export const ENTITY_LOOK: Readonly<Record<AssistantApplied['entity'], EntityLook>> = {
place: {
Icon: MapPin,
onMap: (applied) => {
const placeId = applied.ids[0] ?? null;
return { placeId, dayId: placeId === null ? applied.dayId : null };
},
},
assignment: { Icon: CalendarDays, onMap: BY_DAY },
day_note: { Icon: StickyNote, onMap: BY_DAY },
memory: { Icon: NotebookPen },
other: { Icon: Circle },
...TOUR_LOOK.entities,
...ROADTRIP_LOOK.entities,
...BUDGET_LOOK.entities,
...BOOKING_LOOK.entities,
...COLLAB_LOOK.entities,
...COLLECTION_LOOK.entities,
...ATLAS_LOOK.entities,
...VACAY_LOOK.entities,
...JOURNEY_LOOK.entities,
...PACKING_LOOK.entities,
};
/**
* The look of a verb or an entity as a stored or streamed change names it. A
* newer server may name one this build has no row for (the shared schema
* reads it as `genericUpdate` or `other`, but an older one did not), so an
* unknown name looks like a plain update or a thing with nothing to open.
*/
export function verbLookOf(verbKey: AssistantChangeVerbKey): VerbLook {
return VERB_LOOK[verbKey] ?? VERB_LOOK.genericUpdate;
}
export function entityLookOf(entity: AssistantApplied['entity']): EntityLook {
return ENTITY_LOOK[entity] ?? ENTITY_LOOK.other;
}
/** The pure look of every kind of card. */
export const CARD_LOOKS: { readonly [K in TrekCardKind]: CardLook<K> } = {
...TOUR_LOOK.cards,
...ROADTRIP_LOOK.cards,
...BUDGET_LOOK.cards,
...BOOKING_LOOK.cards,
...COLLAB_LOOK.cards,
...COLLECTION_LOOK.cards,
...ATLAS_LOOK.cards,
...VACAY_LOOK.cards,
...JOURNEY_LOOK.cards,
...PACKING_LOOK.cards,
};
/** The look of the card in hand, typed for any kind. */
function lookOf(card: TrekCard): CardLook<TrekCardKind> {
return CARD_LOOKS[card.kind] as CardLook<TrekCardKind>;
}
/**
* How an answer lays its cards out: the group each joins, and which ones give
* way to a change they served. The chat, the phone, the follow-ups and the
* share text all read these, so none of them groups a card differently.
*/
export const CARD_RULES: CardRules = {
group: (card) => lookOf(card).group?.(card) ?? card.kind,
yieldsToChange: (answer, change) => lookOf(answer).yieldsToChange?.(answer, change) ?? false,
};
/** What the plan's map draws for a card through its kind's look; nothing for a kind that draws nothing. */
export const CARD_MAP_LOOK: CardMapLook = {
lines: (card, use) => lookOf(card).lines?.(card, use) ?? [],
ghosts: (card) => lookOf(card).ghosts?.(card) ?? [],
hidesEffectGhost: (card) => lookOf(card).hidesEffectGhost?.(card) ?? false,
};
@@ -0,0 +1,121 @@
import type {
AssistantApplied,
AssistantChangeVerbKey,
AssistantMessage,
TrekCard,
TrekCardKind,
TrekCardOf,
} from '@trek/shared';
import type { LucideIcon } from 'lucide-react';
import type { ComponentType } from 'react';
import type { TripTabId } from '../../../constants/tripTabs';
import type { GhostPoint, RouteLineSpec, RouteUse } from '../../../types/assistantMap';
import type { FollowUp } from '../assistantFollowUpRules';
import type { ChangeItem } from '../assistantParts';
import type { Tone } from './parts/assistantTones';
/**
* The shapes every part of TREK fills to bring its cards into the chat. A
* part writes a pure look (`<pkg>Look.ts`: verbs, tool names, steps, what an
* applied change opens, follow-ups, map lines) and its views
* (`<pkg>Views.tsx`: the components); `cardLooks.ts` and `cardViews.ts` merge
* them, and a table that misses a kind, a verb or an entity fails to compile.
*/
/** How a kind of change looks: its icon, its tone and the name of what it does. */
export interface VerbLook {
Icon: LucideIcon;
tone: Tone;
labelKey: string;
}
/** `liveKey` names the step with the place or query it is at, `param` is the word that fills it. */
export interface StepLook {
stepKey: string;
live?: { liveKey: string; param: 'place' | 'query' };
}
/** Where a card or an applied change takes the user: a spot on the plan's map, a tab of the trip, another page. */
export type EntityTarget =
| { kind: 'map'; placeId: number | null; dayId: number | null }
| { kind: 'tab'; tab: TripTabId; sub?: string; params?: Record<string, string> }
| { kind: 'page'; path: string };
/** What an applied change touches, and where it can be seen afterwards. */
export interface EntityLook {
Icon: LucideIcon;
openLabelKey?: string;
phoneOpenLabelKey?: string;
onMap?(applied: AssistantApplied, card: TrekCard | null): { placeId: number | null; dayId: number | null } | null;
open?(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null;
}
/**
* The pure side of one kind: what it touches, what to ask next, how an answer
* lays it out, and what the map draws for it (see types/assistantMap.ts). Pure,
* so the chat, the follow-ups, the share text and the map read one layout.
*/
export interface CardLook<K extends TrekCardKind> {
entity?(card: TrekCardOf<K>): AssistantApplied['entity'];
followUps?: readonly FollowUp[];
/** The group an answer card joins; the kind when left out. */
group?(card: TrekCardOf<K>): string;
/** Drops a lookup card next to the change it served. */
yieldsToChange?(answer: TrekCardOf<K>, change: TrekCard): boolean;
lines?(card: TrekCardOf<K>, use: RouteUse): RouteLineSpec[];
ghosts?(card: TrekCardOf<K>): GhostPoint[];
hidesEffectGhost?(card: TrekCardOf<K>): boolean;
}
/** One part of TREK's pure look, for the kinds `K` it owns. */
export interface PackLook<K extends TrekCardKind> {
verbs: Partial<Record<AssistantChangeVerbKey, VerbLook>>;
tools: Readonly<Record<string, { labelKey: string }>>;
steps: Readonly<Record<string, StepLook>>;
entities: Partial<Record<AssistantApplied['entity'], EntityLook>>;
cards: { [P in K]: CardLook<P> };
}
/** One card of an answer, under the tool call it came from. */
export interface CardEntry {
toolCallId: string;
card: TrekCard;
}
/** What a change row's slot gets: the row's card and the row itself. */
interface CardSlotProps<K extends TrekCardKind> {
card: TrekCardOf<K>;
item: ChangeItem;
}
/** A run of change rows a view draws together. */
interface CardRunProps {
items: readonly ChangeItem[];
excluded: ReadonlySet<string>;
onToggle(id: string): void;
}
/**
* How one kind draws. `Block` draws a group of answer cards (grouped by the
* kind's look); the slots fill a change row (`Lead` in front, `Aside` at the
* end of the subject line, `Facts` on the faint line, `Body` under it);
* `Group` heads or closes a run of rows of the kind, `Run` replaces two or
* more waiting rows that can be decided as one. Every member is optional: an
* empty view draws nothing, and one that throws draws its fallback instead.
*/
export interface CardView<K extends TrekCardKind> {
Block?: ComponentType<{ cards: readonly CardEntry[]; message: AssistantMessage }>;
Lead?: ComponentType<CardSlotProps<K>>;
Aside?: ComponentType<CardSlotProps<K>>;
Facts?: ComponentType<CardSlotProps<K>>;
Body?: ComponentType<CardSlotProps<K>>;
ownsSubject?: boolean;
Group?: ComponentType<CardRunProps & { place: 'head' | 'foot' }>;
Run?: ComponentType<CardRunProps>;
runnable?(card: TrekCardOf<K>): boolean;
startsExcluded?(card: TrekCardOf<K>): boolean;
}
/** The view of a kind not drawn yet: it renders nothing, so its cards are left out until a package fills it. */
export const PENDING_VIEW = {} satisfies CardView<TrekCardKind>;
@@ -0,0 +1,37 @@
import type { TrekCard, TrekCardKind } from '@trek/shared';
import { ATLAS_VIEWS } from './atlas/atlasViews';
import { BOOKING_VIEWS } from './booking/bookingViews';
import { BUDGET_VIEWS } from './budget/budgetViews';
import type { CardView } from './cardTypes';
import { COLLAB_VIEWS } from './collab/collabViews';
import { COLLECTION_VIEWS } from './collection/collectionViews';
import { JOURNEY_VIEWS } from './journey/journeyViews';
import { PACKING_VIEWS } from './packing/packingViews';
import { ROADTRIP_VIEWS } from './roadtrip/roadtripViews';
import { TOUR_VIEWS } from './tour/tourViews';
import { VACAY_VIEWS } from './vacay/vacayViews';
/**
* How every kind of card draws, merged from each part of TREK's views. A kind
* without a view of its own yet has the pending one and draws nothing. This
* module pulls in the card components, so only the chat's own chunk imports
* it; the pure helpers, the layout rules among them, read the look instead.
*/
export const CARD_VIEWS: { readonly [K in TrekCardKind]: CardView<K> } = {
...TOUR_VIEWS,
...ROADTRIP_VIEWS,
...BUDGET_VIEWS,
...BOOKING_VIEWS,
...COLLAB_VIEWS,
...COLLECTION_VIEWS,
...ATLAS_VIEWS,
...VACAY_VIEWS,
...JOURNEY_VIEWS,
...PACKING_VIEWS,
};
/** The view of the card in hand, typed for any kind. */
export function viewOf(card: TrekCard): CardView<TrekCardKind> {
return CARD_VIEWS[card.kind] as CardView<TrekCardKind>;
}
@@ -0,0 +1,20 @@
import type { PackLook } from '../cardTypes';
/**
* What the trip chat, polls and notes bring to the chat's pure look: the verbs,
* tool names and steps they add, what an applied change of theirs opens, and
* the look of each of their kinds. Empty until the collab cards fill it. A look
* stays pure, icons and literals only: the chat's main chunk reads every look,
* so whatever a look imports ships with it.
*/
export const COLLAB_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {},
cards: {
collabChat: {},
collabPolls: {},
collabNotes: {},
},
} satisfies PackLook<'collabChat' | 'collabPolls' | 'collabNotes'>;
@@ -0,0 +1,14 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
/**
* How the kinds of the trip chat, polls and notes draw in the chat; each one
* draws nothing until its cards are built. This file only maps each kind to its
* view: the components live in sibling files, since a file that exports
* anything besides components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const COLLAB_VIEWS = {
collabChat: PENDING_VIEW,
collabPolls: PENDING_VIEW,
collabNotes: PENDING_VIEW,
} satisfies { [K in 'collabChat' | 'collabPolls' | 'collabNotes']: CardView<K> };
@@ -0,0 +1,19 @@
import type { PackLook } from '../cardTypes';
/**
* What the collections bring to the chat's pure look: the verbs, tool names and
* steps they add, what an applied change of theirs opens, and the look of each
* of their kinds. Empty until the collections cards fill it. A look stays pure,
* icons and literals only: the chat's main chunk reads every look, so whatever
* a look imports ships with it.
*/
export const COLLECTION_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {},
cards: {
collectionPlaces: {},
collectionLists: {},
},
} satisfies PackLook<'collectionPlaces' | 'collectionLists'>;
@@ -0,0 +1,13 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
/**
* How the kinds of the collections draw in the chat; each one draws nothing
* until its cards are built. This file only maps each kind to its view: the
* components live in sibling files, since a file that exports anything besides
* components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const COLLECTION_VIEWS = {
collectionPlaces: PENDING_VIEW,
collectionLists: PENDING_VIEW,
} satisfies { [K in 'collectionPlaces' | 'collectionLists']: CardView<K> };
@@ -0,0 +1,18 @@
import type { PackLook } from '../cardTypes';
/**
* What the journeys bring to the chat's pure look: the verbs, tool names and
* steps they add, what an applied change of theirs opens, and the look of each
* of their kinds. Empty until the journey cards fill it. A look stays pure,
* icons and literals only: the chat's main chunk reads every look, so whatever
* a look imports ships with it.
*/
export const JOURNEY_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {},
cards: {
journeyEntries: {},
},
} satisfies PackLook<'journeyEntries'>;
@@ -0,0 +1,12 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
/**
* How the kinds of the journeys draw in the chat; each one draws nothing until
* its cards are built. This file only maps each kind to its view: the
* components live in sibling files, since a file that exports anything besides
* components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const JOURNEY_VIEWS = {
journeyEntries: PENDING_VIEW,
} satisfies { [K in 'journeyEntries']: CardView<K> };
@@ -0,0 +1,24 @@
import { ListTodo, Luggage } from 'lucide-react';
import type { PackLook } from '../cardTypes';
/**
* What the packing lists and to-dos bring to the chat's pure look: the verbs,
* tool names and steps they add, what an applied change of theirs opens, and
* the look of each of their kinds. Applied items have no way to open yet, as
* before. A look stays pure, icons and literals only: the chat's main chunk
* reads every look, so whatever a look imports ships with it.
*/
export const PACKING_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {
packing: { Icon: Luggage },
todo: { Icon: ListTodo },
},
cards: {
packingList: {},
todoList: {},
},
} satisfies PackLook<'packingList' | 'todoList'>;
@@ -0,0 +1,13 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
/**
* How the kinds of the packing lists and to-dos draw in the chat; each one
* draws nothing until its cards are built. This file only maps each kind to its
* view: the components live in sibling files, since a file that exports
* anything besides components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const PACKING_VIEWS = {
packingList: PENDING_VIEW,
todoList: PENDING_VIEW,
} satisfies { [K in 'packingList' | 'todoList']: CardView<K> };
@@ -0,0 +1,17 @@
import type { ReactNode } from 'react';
import ErrorBoundary from '../../../shared/ErrorBoundary';
/**
* Around every card and every slot of a kind's view the chat draws: one that
* throws while it renders is logged and drawn as its `fallback` (nothing,
* unless the caller hands what the row shows without a card), and the answer
* around it stays.
*/
export default function CardBoundary({ children, fallback = null }: { children: ReactNode; fallback?: ReactNode }) {
return (
<ErrorBoundary boundaryId="assistant-card" fallback={fallback}>
{children}
</ErrorBoundary>
);
}
@@ -0,0 +1,83 @@
import { ArrowRight, type LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../../i18n';
import { type PillTone, SoftPill } from '../../../Planner/planParts';
/**
* The facts a card states in one wrapped line of small white badges, the way
* the planner's rows do: a fact with its glyph, a value that changes from old
* to new, what a change does to a row, and what no longer belongs to it.
*/
export function CardFacts({ children }: { children: ReactNode }) {
return <span className="mt-1 flex flex-wrap items-center gap-1">{children}</span>;
}
/** A value that changes: the old one struck through, an arrow, the new one. Without `before`, only the value. */
export function FactChange({
icon: Icon,
before,
after,
tone = 'neutral',
}: {
icon: LucideIcon;
before: string | null;
after: string;
tone?: PillTone;
}) {
return (
<SoftPill
tone={tone}
className="max-w-full tabular-nums"
icon={
<Icon
size={10}
strokeWidth={2.2}
aria-hidden="true"
className={tone === 'neutral' ? 'flex-none text-content-faint' : 'flex-none'}
/>
}
>
{before !== null && (
<>
<bdi className="truncate text-content-faint line-through">{before}</bdi>
<ArrowRight size={9} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
</>
)}
<bdi className="truncate">{after}</bdi>
</SoftPill>
);
}
/** One fact with its glyph. */
export function Fact({ icon, tone, label }: { icon: LucideIcon; tone?: PillTone; label: string }) {
return <FactChange icon={icon} tone={tone} before={null} after={label} />;
}
/** What a change does to a row of a card. A table, so each word is named where it is read. */
const CHANGE_MARK = {
new: { labelKey: 'assistant.card.common.new', tone: 'success' },
removed: { labelKey: 'assistant.card.common.removed', tone: 'danger' },
changed: { labelKey: 'assistant.card.common.changed', tone: 'info' },
willDelete: { labelKey: 'assistant.card.common.willDelete', tone: 'danger' },
} as const satisfies Record<string, { labelKey: string; tone: PillTone }>;
/** The mark of a row a change touches; nothing for a row it leaves alone (`none`, an answer's rows). */
export function ChangeMark({ change }: { change: keyof typeof CHANGE_MARK | 'none' }) {
const { t } = useTranslation();
if (change === 'none') return null;
const mark = CHANGE_MARK[change];
return <SoftPill tone={mark.tone}>{t(mark.labelKey)}</SoftPill>;
}
/** The names a change takes off a row (people, places), in one line. */
export function NoLonger({ names }: { names: readonly string[] }) {
const { t } = useTranslation();
if (names.length === 0) return null;
return (
<span className="text-caption text-content-muted">
<bdi>{t('assistant.card.common.noLonger', { names: names.join(', ') })}</bdi>
</span>
);
}
@@ -0,0 +1,42 @@
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
* 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.
*/
type FrameState = 'answer' | 'pending' | 'removed' | 'settled';
const STATE: Record<FrameState, string> = {
answer: 'border-edge-faint',
pending: 'border-dashed border-edge',
removed: 'border-edge-faint opacity-60',
settled: 'border-edge-faint opacity-50',
};
export default function CardFrame({
label,
state = 'answer',
surface = 'card',
children,
}: {
/** What the card is, for a screen reader. */
label: string;
state?: FrameState;
surface?: 'card' | 'secondary';
children: ReactNode;
}) {
return (
<section
aria-label={label}
className={`trek-assistant-enter rounded-xl border px-3 pb-2 pt-2.5 shadow-card ${STATE[state]} ${
surface === 'secondary' ? 'bg-surface-secondary' : 'bg-surface-card'
}`}
>
{children}
</section>
);
}
@@ -0,0 +1,43 @@
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { WhiteBadge } from '../../../Planner/planParts';
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.
*/
export default function CardHeader({
icon,
tone,
title,
count,
meta,
actions,
}: {
icon: LucideIcon;
tone: Tone;
title: string;
count?: number;
meta?: ReactNode;
actions?: 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>
{meta && <div className="mt-0.5 text-caption text-content-muted">{meta}</div>}
</div>
{actions && <div className="flex flex-none items-center gap-1">{actions}</div>}
</div>
);
}
@@ -0,0 +1,51 @@
import { ArrowUpRight } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { Tooltip } from '../../../shared/Tooltip';
import { useAssistantView } from '../../assistantViewContext';
import type { EntityTarget } from '../cardTypes';
/**
* The ways out of a card into TREK: "Open" to where the card's things live,
* and a day to show in the plan. Both go through the shell's one way of
* opening things, and neither shows where the shell cannot open anything.
*/
/** "Open" (or the card's own words) as a quiet text button; taller on the phone. */
export function CardOpenButton({ target, labelKey }: { target: EntityTarget; labelKey?: string }) {
const { t } = useTranslation();
const { variant, onOpen } = useAssistantView();
if (!onOpen) return null;
return (
<button
type="button"
onClick={() => onOpen(target)}
className={`inline-flex flex-none items-center gap-1 rounded-full px-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover hover:text-content ${
variant === 'fullscreen' ? 'h-10' : 'h-7'
}`}
>
{labelKey ? t(labelKey) : t('assistant.card.common.open')}
<ArrowUpRight size={12} aria-hidden="true" className="rtl:-scale-x-100" />
</button>
);
}
/** A day named on a card, as a button that shows it in the plan. */
export function CardDayButton({ dayId, label }: { dayId: number; label: string }) {
const { t } = useTranslation();
const { onOpen } = useAssistantView();
if (!onOpen) return <bdi className="text-caption text-content-secondary">{label}</bdi>;
const hint = t('assistant.card.common.showDay');
return (
<Tooltip label={hint} placement="top">
<button
type="button"
onClick={() => onOpen({ kind: 'map', placeId: null, dayId })}
aria-label={`${label}: ${hint}`}
className="rounded-full px-1 text-caption text-content-secondary underline-offset-2 hover:text-content hover:underline"
>
<bdi>{label}</bdi>
</button>
</Tooltip>
);
}
@@ -0,0 +1,193 @@
/**
* FE-AI-CARDPART-001 to FE-AI-CARDPART-009
*
* The pieces every card is built from: its tile, frame and head, its facts
* and change marks, its rows and the way to the rest, its "Open" and day
* buttons, the people on it, and the boundary that leaves a broken card out.
*/
import type { CardPerson } from '@trek/shared';
import { CalendarDays, Route } from 'lucide-react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../../../tests/helpers/factories';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { useAuthStore } from '../../../../store/authStore';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import CardBoundary from './CardBoundary';
import { CardFacts, ChangeMark, Fact, FactChange, NoLonger } from './CardFacts';
import CardFrame from './CardFrame';
import CardHeader from './CardHeader';
import { CardDayButton, CardOpenButton } from './CardOpen';
import { PeopleStack, PersonFace } from './CardPeople';
import { CardMore, CardRow, CardRows } from './CardRows';
import KindTile from './KindTile';
const AUTH = useAuthStore.getState();
function inView(ui: ReactNode, view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
}
afterEach(() => {
useAuthStore.setState(AUTH, true);
vi.restoreAllMocks();
});
describe('card parts', () => {
it('FE-AI-CARDPART-001: the tile wears its tone, fades when dimmed and waits as a placeholder without an icon', () => {
const { container, rerender } = render(<KindTile icon={Route} tone="success" size="xs" />);
expect(container.firstElementChild).toHaveClass('bg-success-soft', 'ring-2');
rerender(<KindTile icon={Route} tone="danger" dimmed />);
expect(container.firstElementChild).toHaveClass('bg-surface-secondary', 'h-8');
rerender(<KindTile icon={null} tone="info" size="sm" />);
expect(container.firstElementChild).toHaveClass('trek-skeleton', 'h-7');
});
it('FE-AI-CARDPART-002: the frame names the card and says its state; the head titles, counts and acts', () => {
render(
<CardFrame label="Tours" state="pending" surface="secondary">
<CardHeader
icon={Route}
tone="info"
title="Three ways up"
count={3}
meta="Day 2"
actions={<button type="button">act</button>}
/>
</CardFrame>
);
const frame = screen.getByRole('region', { name: 'Tours' });
expect(frame).toHaveClass('border-dashed', 'bg-surface-secondary');
expect(screen.getByText('Three ways up')).toHaveAttribute('title', 'Three ways up');
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('Day 2')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'act' })).toBeInTheDocument();
});
it('FE-AI-CARDPART-003: facts read old to new, change marks name what happens, and a list says who is gone', () => {
render(
<CardFacts>
<Fact icon={CalendarDays} label="Day 3" />
<FactChange icon={CalendarDays} tone="warning" before="09:00" after="10:30" />
<ChangeMark change="new" />
<ChangeMark change="willDelete" />
<ChangeMark change="none" />
<NoLonger names={['Ana', 'Ben']} />
<NoLonger names={[]} />
</CardFacts>
);
expect(screen.getByText('Day 3')).toBeInTheDocument();
expect(screen.getByText('09:00')).toHaveClass('line-through');
expect(screen.getByText('10:30')).toBeInTheDocument();
expect(screen.getByText('New')).toBeInTheDocument();
expect(screen.getByText('Will be deleted')).toBeInTheDocument();
expect(screen.getByText('No longer: Ana, Ben')).toBeInTheDocument();
});
it('FE-AI-CARDPART-004: a row that leads somewhere is a button and lights the map while the pointer rests on it', () => {
const onActivate = vi.fn();
const onHover = vi.fn();
inView(
<CardRows>
<CardRow title="Col du Galibier" aside="12 km" lit onActivate={onActivate} onHover={onHover} />
<CardRow title="Quiet row" />
</CardRows>,
{ variant: 'fullscreen' }
);
const row = screen.getByRole('button', { name: /Col du Galibier/ });
expect(row).toHaveClass('bg-surface-hover', 'min-h-11');
fireEvent.mouseEnter(row);
fireEvent.mouseLeave(row);
expect(onHover.mock.calls).toEqual([[true], [false]]);
fireEvent.click(row);
expect(onActivate).toHaveBeenCalled();
expect(screen.getByText('Quiet row').closest('button')).toBeNull();
});
it('FE-AI-CARDPART-005: "N more" opens where the rest lives, and is plain words where nothing can open it', () => {
const onOpen = vi.fn();
const target = { kind: 'tab', tab: 'finanzplan' } as const;
const first = inView(<CardMore count={4} target={target} />, { onOpen });
fireEvent.click(screen.getByRole('button', { name: '4 more' }));
expect(onOpen).toHaveBeenCalledWith(target);
first.unmount();
const second = inView(<CardMore count={1} labelKey="assistant.place.more" target={target} />);
expect(screen.queryByRole('button')).toBeNull();
expect(screen.getByText('1 more')).toBeInTheDocument();
second.unmount();
const { container } = inView(<CardMore count={0} target={target} />, { onOpen });
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-CARDPART-006: "Open" and a day go through the shell, taller on the phone, and stay out without a shell', () => {
const onOpen = vi.fn();
const first = inView(
<>
<CardOpenButton target={{ kind: 'page', path: '/atlas' }} />
<CardDayButton dayId={8} label="Day 2" />
</>,
{ onOpen, variant: 'fullscreen' }
);
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/atlas' });
expect(screen.getByRole('button', { name: 'Open' })).toHaveClass('h-10');
fireEvent.click(screen.getByRole('button', { name: 'Day 2: Show this day in the plan' }));
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'map', placeId: null, dayId: 8 });
first.unmount();
inView(
<>
<CardOpenButton target={{ kind: 'page', path: '/atlas' }} labelKey="assistant.change.viewOnMap" />
<CardDayButton dayId={8} label="Day 2" />
</>
);
expect(screen.queryByRole('button')).toBeNull();
expect(screen.getByText('Day 2')).toBeInTheDocument();
});
it('FE-AI-CARDPART-007: the signed-in user reads as You, a picture comes from TREK only, the rest is counted', () => {
useAuthStore.setState({ user: buildUser({ id: 1 }) });
const people: CardPerson[] = [
{ id: 1, name: 'Ana', avatarKey: 'ana.png' },
{ id: 2, name: 'Ben', avatarKey: null },
{ id: 3, name: 'Cleo', avatarKey: null },
{ id: 4, name: 'Dan', avatarKey: null },
];
const { container } = render(
<>
<PersonFace person={people[0] as CardPerson} size={24} />
<PeopleStack people={people} max={2} />
</>
);
expect(screen.getByRole('img', { name: 'You' })).toBeInTheDocument();
expect(container.querySelector('img')?.getAttribute('src')).toBe('/uploads/avatars/ana.png');
expect(screen.getByRole('img', { name: 'You, Ben, Cleo, Dan' })).toBeInTheDocument();
expect(screen.getByText('+2')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
});
it('FE-AI-CARDPART-008: nobody on a card draws nothing', () => {
const { container } = render(<PeopleStack people={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-CARDPART-009: a card that throws is left out and the rest stays', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
function Broken(): never {
throw new Error('broken');
}
render(
<div>
<CardBoundary>
<Broken />
</CardBoundary>
<span>still here</span>
</div>
);
expect(screen.getByText('still here')).toBeInTheDocument();
});
});
@@ -0,0 +1,61 @@
import type { CardPerson } from '@trek/shared';
import { useTranslation } from '../../../../i18n';
import { avatarSrc } from '../../../../utils/avatarSrc';
import { TravelerAvatar } from '../../../Planner/bookings/bookingParts';
import { Tooltip } from '../../../shared/Tooltip';
import { useViewerId } from './useViewerId';
/**
* People on a card, as the bookings show travellers: a round face with the
* picture TREK stores for them, else the first letter. A card carries a
* stored file name only, never an address, so a picture from a sign-in
* provider shows the letter. The signed-in user reads as "You".
*/
const FACE = { 14: 'h-3.5 w-3.5', 20: 'h-5 w-5', 24: 'h-6 w-6' } as const;
/** A person's name as the card says it: "You" for the signed-in user. */
function useNameOf(): (person: CardPerson) => string {
const { t } = useTranslation();
const viewerId = useViewerId();
const you = t('assistant.card.common.you');
return (person) => (person.id === viewerId ? you : person.name);
}
export function PersonFace({ person, size = 20 }: { person: CardPerson; size?: keyof typeof FACE }) {
const nameOf = useNameOf();
const name = nameOf(person);
return (
<span role="img" aria-label={name} title={name} className="inline-flex flex-none">
<TravelerAvatar src={avatarSrc(person.avatarKey)} name={person.name} className={FACE[size]} />
</span>
);
}
/** A few faces stacked, the rest counted, every name in the tooltip and for screen readers. */
export function PeopleStack({ people, max = 3 }: { people: readonly CardPerson[]; max?: number }) {
const nameOf = useNameOf();
if (people.length === 0) return null;
const names = people.map(nameOf).join(', ');
return (
<Tooltip label={names}>
<span role="img" aria-label={names} className="flex items-center">
{people.slice(0, max).map((person, i) => (
<TravelerAvatar
key={person.id}
src={avatarSrc(person.avatarKey)}
name={person.name}
className="h-5 w-5 border-2 border-surface-card"
style={{ marginInlineStart: i ? -6 : 0 }}
/>
))}
{people.length > max && (
<span className="ms-1 text-caption font-semibold tabular-nums text-content-faint">
+{people.length - max}
</span>
)}
</span>
</Tooltip>
);
}
@@ -0,0 +1,89 @@
import type { ReactNode } from 'react';
import { useTranslation } from '../../../../i18n';
import { useAssistantView } from '../../assistantViewContext';
import type { EntityTarget } from '../cardTypes';
/**
* The rows of an answer card, like the planner's day list: a lead in front,
* the name in bold, an aside at the end (an amount, a time) and its facts on
* the line under it. A row that leads somewhere is a button; resting on it
* can light its spot on the map. What a card leaves out is counted at the end
* as a way to where it all lives, never unfolded in the chat.
*/
export function CardRows({ children }: { children: ReactNode }) {
return <ul className="m-0 flex list-none flex-col p-0">{children}</ul>;
}
export function CardRow({
lead,
title,
aside,
facts,
onActivate,
onHover,
lit = false,
}: {
lead?: ReactNode;
title: ReactNode;
aside?: ReactNode;
facts?: ReactNode;
onActivate?: () => void;
onHover?: (on: boolean) => void;
lit?: boolean;
}) {
const { variant } = useAssistantView();
const look = `-mx-1.5 flex w-full gap-2.5 rounded-[12px] px-1.5 py-2 text-start transition-colors hover:bg-surface-hover ${
variant === 'fullscreen' ? 'min-h-11' : ''
} ${lit ? 'bg-surface-hover' : ''}`;
const body = (
<>
{lead}
<span className="min-w-0 flex-1">
<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>}
</>
);
const hover = onHover
? {
onMouseEnter: () => onHover(true),
onMouseLeave: () => onHover(false),
onFocus: () => onHover(true),
onBlur: () => onHover(false),
}
: {};
return (
<li>
{onActivate ? (
<button type="button" onClick={onActivate} className={look} {...hover}>
{body}
</button>
) : (
<div className={look} {...hover}>
{body}
</div>
)}
</li>
);
}
/** "3 more" at the end of a card: a text button to where the rest lives, or plain words where nothing can open it. */
export function CardMore({ count, labelKey, target }: { count: number; labelKey?: string; target: EntityTarget }) {
const { t } = useTranslation();
const { onOpen } = useAssistantView();
if (count <= 0) return null;
const label = labelKey ? t(labelKey, { count }) : t('assistant.card.common.more', { count });
if (!onOpen) return <p className="m-0 px-1.5 py-1 text-caption text-content-muted">{label}</p>;
return (
<button
type="button"
onClick={() => onOpen(target)}
className="rounded-lg px-1.5 py-1 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
>
{label}
</button>
);
}
@@ -0,0 +1,74 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { ReactNode } from 'react';
import { useAssistantView } from '../../assistantViewContext';
import { useCardStrip } from './useCardStrip';
// In the strip's head, never over a card: a press that ends a pixel off still lands on the arrow.
const ARROW =
'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content disabled:cursor-default disabled:opacity-40 disabled:hover:text-content-secondary';
/**
* A row of cards to swipe or scroll through, running out to the chat's
* edges: the next card peeks in, so the row says it goes on, and the edges
* fade where there is more. In the desktop window a head over the row names
* it, counts it and holds two arrows that move it by whole cards, off while
* that way is the end. `children` are the cards, each an `li`.
*/
export default function CardStrip({
label,
count,
watch,
children,
}: {
label: string;
count: number;
/** What the strip shows; a change measures it again. */
watch: unknown;
children: ReactNode;
}) {
const { variant } = useAssistantView();
const { listRef, fade, measure, page } = useCardStrip(watch);
return (
<section aria-label={label} className="-mx-4">
{variant === 'window' && (fade.start || fade.end) && (
// The cards are reachable by Tab; the arrows are for the pointer only.
<div className="mb-1.5 flex items-center gap-2 px-4">
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-muted">
{label}
<span className="ms-1.5 tabular-nums text-content-faint">{count}</span>
</span>
<button
type="button"
tabIndex={-1}
aria-hidden="true"
disabled={!fade.start}
onClick={() => page(-1)}
className={ARROW}
>
<ChevronLeft size={15} className="rtl:-scale-x-100" />
</button>
<button
type="button"
tabIndex={-1}
aria-hidden="true"
disabled={!fade.end}
onClick={() => page(1)}
className={ARROW}
>
<ChevronRight size={15} className="rtl:-scale-x-100" />
</button>
</div>
)}
<ul
ref={listRef}
onScroll={measure}
data-fade-start={fade.start || undefined}
data-fade-end={fade.end || undefined}
className="trek-assistant-strip flex snap-x snap-mandatory scroll-px-4 gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2 pt-0.5 motion-safe:scroll-smooth"
>
{children}
</ul>
</section>
);
}
@@ -0,0 +1,41 @@
import type { LucideIcon } from 'lucide-react';
import { TILE, type Tone } from './assistantTones';
/**
* The tinted tile a change or a card wears: the icon of what it touches in
* the tone of what happens. `xs` is the small badge on a corner (an avatar,
* a photo), `sm` sits in front of a row, `md` heads a card. Without an icon
* yet (the preview has not arrived) it shows as a placeholder.
*/
const BOX = {
xs: 'h-3.5 w-3.5 rounded-full ring-2 ring-surface-card',
sm: 'h-7 w-7 rounded-md',
md: 'h-8 w-8 rounded-lg',
} as const;
const GLYPH = { xs: 9, sm: 14, md: 16 } as const;
export default function KindTile({
icon: Icon,
tone,
size = 'md',
dimmed = false,
}: {
icon: LucideIcon | null;
tone: Tone;
size?: 'xs' | 'sm' | 'md';
dimmed?: boolean;
}) {
const box = BOX[size];
if (!Icon) return <span className={`trek-skeleton flex-shrink-0 ${box}`} aria-hidden="true" />;
return (
<span
aria-hidden="true"
className={`flex flex-shrink-0 items-center justify-center ${box} ${dimmed ? 'bg-surface-secondary text-content-faint' : TILE[tone]}`}
>
<Icon size={GLYPH[size]} strokeWidth={2.1} />
</span>
);
}
@@ -0,0 +1,76 @@
/**
* FE-AI-SKETCH-001 to FE-AI-SKETCH-004
*
* The small route drawing: framed by everything it holds in Web Mercator, a
* lone point centred, nothing without a point, and a button when it shows
* the route on the map.
*/
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import RouteSketch from './RouteSketch';
import { linePoints, mercatorY, sketchFrame, type SketchLine, type SketchMark } from './routeSketchModel';
const LINE: SketchLine = {
id: 'day-1',
points: [
[45.0, 7.0],
[45.2, 7.4],
],
color: 'var(--accent)',
casing: 'var(--bg-card)',
};
const MARKS: SketchMark[] = [
{ id: 's1', lat: 45.0, lng: 7.0, shape: 'stop' },
{ id: 'v1', lat: 45.1, lng: 7.2, shape: 'via', state: 'pending', hot: true },
{ id: 'c1', lat: 45.2, lng: 7.4, shape: 'service', color: 'var(--warning)' },
{ id: 'n1', lat: 45.15, lng: 7.3, shape: 'new', state: 'removed' },
];
describe('routeSketchModel', () => {
it('FE-AI-SKETCH-001: projects like the map, inside the box with room around it, north up', () => {
expect(mercatorY(0)).toBeCloseTo(0);
expect(mercatorY(89)).toBe(mercatorY(85));
const frame = sketchFrame([LINE], []);
if (!frame) throw new Error('no frame');
expect(frame.width).toBe(1000);
const [x0, y0] = frame.project(45.0, 7.0);
const [x1, y1] = frame.project(45.2, 7.4);
expect(x0).toBeGreaterThan(0);
expect(x1).toBeLessThan(frame.width);
expect(y1).toBeLessThan(y0);
expect(y0).toBeLessThan(frame.height);
expect(linePoints(frame, LINE.points).split(' ')).toHaveLength(2);
});
it('FE-AI-SKETCH-002: one point sits in the middle, and no point gives no frame', () => {
const frame = sketchFrame([], [{ id: 'a', lat: 45, lng: 7, shape: 'stop' }]);
expect(frame?.project(45, 7)).toEqual([500, (frame?.height ?? 0) / 2]);
expect(sketchFrame([], [])).toBeNull();
});
});
describe('RouteSketch', () => {
it('FE-AI-SKETCH-003: draws each line twice (casing and core) and every mark, as one picture', () => {
const { container } = render(
<RouteSketch lines={[{ ...LINE, dashed: true, dim: true }]} marks={MARKS} height={96} ariaLabel="Day 1" />
);
expect(screen.getByRole('img', { name: 'Day 1' })).toBeInTheDocument();
expect(container.querySelectorAll('polyline')).toHaveLength(2);
expect(container.querySelector('rect')).not.toBeNull();
expect(container.querySelectorAll('circle')).toHaveLength(4);
});
it('FE-AI-SKETCH-004: with a way to the map it is a button, and nothing is drawn without a point', () => {
const onActivate = vi.fn();
const first = render(
<RouteSketch lines={[LINE]} marks={[]} height={96} ariaLabel="Day 1" onActivate={onActivate} />
);
fireEvent.click(screen.getByRole('button', { name: 'Day 1' }));
expect(onActivate).toHaveBeenCalled();
first.unmount();
const { container } = render(<RouteSketch lines={[]} marks={[]} height={96} ariaLabel="Nothing" />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,122 @@
import { linePoints, sketchFrame, type SketchLine, type SketchMark } from './routeSketchModel';
/**
* A route drawn small, without tiles or a network: the lines and stops of a
* drive or a hike projected as the map projects them, on the quiet secondary
* surface. Lines keep their width however the box scales; marks are sized
* for a card a little wider than the phone. The sketch is geographic, so it
* reads the same in both directions of text. With `onActivate` it is a
* button (show it on the map).
*/
/** The card width the marks are sized for: a stop stays about three pixels across in a card this wide. */
const CARD_WIDTH = 360;
function Mark({ mark, x, y, unit }: { mark: SketchMark; x: number; y: number; unit: number }) {
const dashed = mark.state === 'pending' ? `${2 * unit} ${2 * unit}` : undefined;
const stroke = mark.state === 'removed' ? 'var(--text-faint)' : 'var(--text-muted)';
return (
<g>
{mark.hot && <circle cx={x} cy={y} r={6 * unit} fill="none" stroke="var(--accent)" strokeWidth={unit} />}
{mark.shape === 'via' ? (
<rect
x={x - 3.5 * unit}
y={y - 3.5 * unit}
width={7 * unit}
height={7 * unit}
transform={`rotate(45 ${x} ${y})`}
fill="var(--accent)"
strokeDasharray={dashed}
/>
) : (
<circle
cx={x}
cy={y}
r={(mark.shape === 'service' ? 4 : 3) * unit}
fill={mark.shape === 'service' ? (mark.color ?? 'var(--text-muted)') : 'var(--bg-card)'}
stroke={mark.shape === 'new' ? 'var(--accent)' : stroke}
strokeWidth={unit}
strokeDasharray={mark.shape === 'new' ? `${2 * unit} ${2 * unit}` : dashed}
/>
)}
</g>
);
}
export default function RouteSketch({
lines,
marks,
height,
ariaLabel,
onActivate,
}: {
lines: readonly SketchLine[];
marks: readonly SketchMark[];
/** The box's height in pixels; its width is the card's. */
height: number;
ariaLabel: string;
onActivate?: () => void;
}) {
const frame = sketchFrame(lines, marks);
if (!frame) return null;
const svg = (
<svg
role={onActivate ? undefined : 'img'}
aria-label={onActivate ? undefined : ariaLabel}
aria-hidden={onActivate ? true : undefined}
viewBox={`0 0 ${frame.width} ${frame.height}`}
preserveAspectRatio="xMidYMid meet"
className="block h-full w-full"
>
{lines.map((line) => {
const points = linePoints(frame, line.points);
return (
<g key={line.id} opacity={line.dim ? 0.4 : 1}>
<polyline
points={points}
fill="none"
stroke={line.casing}
strokeWidth={4.5}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
<polyline
points={points}
fill="none"
stroke={line.color}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={line.dashed ? '5 4' : undefined}
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
{marks.map((mark) => {
const [x, y] = frame.project(mark.lat, mark.lng);
return <Mark key={mark.id} mark={mark} x={x} y={y} unit={frame.width / CARD_WIDTH} />;
})}
</svg>
);
const box = 'block w-full overflow-hidden rounded-xl bg-surface-secondary ring-1 ring-inset ring-edge-faint';
if (!onActivate) {
return (
<div className={box} style={{ height }}>
{svg}
</div>
);
}
return (
<button
type="button"
onClick={onActivate}
aria-label={ariaLabel}
className={`${box} transition-shadow hover:ring-edge focus-visible:ring-2 focus-visible:ring-accent`}
style={{ height }}
>
{svg}
</button>
);
}
@@ -0,0 +1,31 @@
import { Check, CircleSlash, LoaderCircle, type LucideIcon, TriangleAlert } from 'lucide-react';
import type { ChangeState } from '../../assistantParts';
/**
* The tones a card speaks in, soft ones only: green adds, blue changes, amber
* moves or warns, red takes away. Every tile, badge and mark of the cards
* takes its colour from here, so a kind never invents its own.
*/
export type Tone = 'success' | 'info' | 'warning' | 'danger';
/** A tinted tile in a tone: the soft background with the strong colour on top. */
export const TILE: Record<Tone, string> = {
success: 'bg-success-soft text-success',
info: 'bg-info-soft text-info',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
};
/** How a decided change reads: one quiet mark, its words in the tooltip. */
export const RECEIPT: Record<Exclude<ChangeState, 'pending'>, { labelKey: string; Icon: LucideIcon; tone: string }> = {
applying: { labelKey: 'assistant.change.applying', Icon: LoaderCircle, tone: 'text-content-muted' },
applied: { labelKey: 'assistant.change.applied', Icon: Check, tone: 'text-success' },
failed: { labelKey: 'assistant.change.failed', Icon: TriangleAlert, tone: 'text-danger' },
discarded: { labelKey: 'assistant.change.discarded', Icon: CircleSlash, tone: 'text-content-faint' },
expired: { labelKey: 'assistant.change.expired', Icon: CircleSlash, tone: 'text-content-faint' },
};
/** A quiet square icon button on a card row or head. */
export const ICON_BUTTON =
'flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-50';
@@ -0,0 +1,90 @@
/**
* FE-AI-CARDHOOK-001 to FE-AI-CARDHOOK-004
*
* The hooks a card leans on: how it writes numbers for this user, its part
* of the map (a pin while resting, a pan on a click, the pin gone with the
* card), and who is signed in.
*/
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../../../tests/helpers/factories';
import { TranslationProvider } from '../../../../i18n/TranslationContext';
import { useAssistantStore } from '../../../../store/assistantStore';
import { useAuthStore } from '../../../../store/authStore';
import { useSettingsStore } from '../../../../store/settingsStore';
import { AssistantViewContext } from '../../assistantViewContext';
import { useCardFormat } from './useCardFormat';
import { useCardPoint } from './useCardPoint';
import { useViewerId } from './useViewerId';
const ASSISTANT = useAssistantStore.getState();
const AUTH = useAuthStore.getState();
const SETTINGS = useSettingsStore.getState();
const onRevealMap = vi.fn();
function wrapper({ children }: { children: ReactNode }) {
return (
<TranslationProvider>
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'fullscreen', onRevealMap }}>
{children}
</AssistantViewContext.Provider>
</TranslationProvider>
);
}
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useAuthStore.setState(AUTH, true);
useSettingsStore.setState(SETTINGS, true);
vi.clearAllMocks();
});
describe('card hooks', () => {
it('FE-AI-CARDHOOK-001: numbers follow the user: clock setting, units, money in its currency, minutes as hours', () => {
useSettingsStore.setState({ settings: { ...SETTINGS.settings, time_format: '12h', distance_unit: 'imperial' } });
const { result } = renderHook(() => useCardFormat(), { wrapper });
const format = result.current;
expect(format.clock('14:30')).toBe('2:30 PM');
expect(format.distance(10)).toBe('6.2 mi');
expect(format.elevation(100)).toBe('328 ft');
expect(format.money({ amount: 12.5, currency: 'USD' })).toBe('$12.50');
expect(format.minutes(45)).toMatch(/45/);
expect(format.minutes(120)).toMatch(/^2/);
expect(format.minutes(135)).toMatch(/2.*15/);
expect(format.count(1200)).toMatch(/1.?200/);
expect(format.date('2026-10-22')).toMatch(/22/);
});
it('FE-AI-CARDHOOK-002: resting on a spot pins it, a click steps the chat aside and pans there', () => {
const setPreviewPin = vi.fn();
const requestCamera = vi.fn();
useAssistantStore.setState({ setPreviewPin, requestCamera });
const { result } = renderHook(() => useCardPoint(), { wrapper });
result.current.hover({ ref: 'r1:0', lat: 45, lng: 7, name: 'Col', placeId: 9 });
const pin = { tripId: 4, ref: 'r1:0', lat: 45, lng: 7, name: 'Col', placeId: 9 };
expect(setPreviewPin).toHaveBeenLastCalledWith(pin);
result.current.hover(null);
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
result.current.show({ lat: 45, lng: 7 });
expect(onRevealMap).toHaveBeenCalled();
expect(requestCamera).toHaveBeenCalledWith(4, [[45, 7]], 'pan', 'user');
});
it('FE-AI-CARDHOOK-003: the pin goes with the card', () => {
const setPreviewPin = vi.fn();
useAssistantStore.setState({ setPreviewPin });
const { unmount } = renderHook(() => useCardPoint(), { wrapper });
unmount();
expect(setPreviewPin).toHaveBeenLastCalledWith(null);
});
it('FE-AI-CARDHOOK-004: the viewer is the signed-in user, or nobody', () => {
useAuthStore.setState({ user: buildUser({ id: 7 }) });
expect(renderHook(() => useViewerId()).result.current).toBe(7);
useAuthStore.setState({ user: null });
expect(renderHook(() => useViewerId()).result.current).toBeNull();
});
});
@@ -0,0 +1,88 @@
/**
* The geometry of a route sketch: lines and marks projected the way the map
* projects them (Web Mercator), framed by everything they hold with a little
* room around it, in a box 1000 units wide. Pure, so the sketch and a test
* read the same numbers. Points are `[lat, lng]`.
*/
/** One line to draw: a day of a drive, a way of a hike. Colours are CSS values the caller made safe. */
export interface SketchLine {
id: string;
points: readonly (readonly [number, number])[];
color: string;
casing: string;
dashed?: boolean;
dim?: boolean;
}
/** One mark on the sketch: a stop, a service stop, a via the route passes, a stop a change adds. */
export interface SketchMark {
id: string;
lat: number;
lng: number;
shape: 'stop' | 'service' | 'via' | 'new';
color?: string;
state?: 'pending' | 'removed';
hot?: boolean;
}
/** The width of the sketch's box in its own units; its height follows the route's shape. */
const SKETCH_WIDTH = 1000;
/** The room around everything, as a share of the longer side. */
const PADDING = 0.08;
/** The box of a sketch with a single point, which has no shape of its own. */
const POINT_HEIGHT = 400;
/** The tallest box a narrow route gets, so a line running north does not turn into a needle. */
const MAX_HEIGHT = 2000;
const RAD = Math.PI / 180;
/** Web Mercator's y for a latitude, in the units of a longitude in radians. */
export function mercatorY(lat: number): number {
const clamped = Math.max(-85, Math.min(85, lat));
return Math.log(Math.tan(Math.PI / 4 + (clamped * RAD) / 2));
}
interface SketchFrame {
width: number;
height: number;
/** A point's place in the box: `x` to the east, `y` down. */
project(lat: number, lng: number): [number, number];
}
/** The frame of everything a sketch draws, or null when it holds no point at all. */
export function sketchFrame(lines: readonly SketchLine[], marks: readonly SketchMark[]): SketchFrame | null {
const points: [number, number][] = [
...lines.flatMap((line) => line.points.map(([lat, lng]): [number, number] => [lng * RAD, mercatorY(lat)])),
...marks.map((mark): [number, number] => [mark.lng * RAD, mercatorY(mark.lat)]),
];
if (points.length === 0) return null;
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
const minX = Math.min(...xs);
const maxY = Math.max(...ys);
const spanX = Math.max(...xs) - minX;
const spanY = maxY - Math.min(...ys);
const side = Math.max(spanX, spanY);
if (side === 0) {
return { width: SKETCH_WIDTH, height: POINT_HEIGHT, project: () => [SKETCH_WIDTH / 2, POINT_HEIGHT / 2] };
}
const pad = side * PADDING;
const scale = SKETCH_WIDTH / (spanX + 2 * pad);
const height = Math.min(MAX_HEIGHT, Math.round((spanY + 2 * pad) * scale));
return {
width: SKETCH_WIDTH,
height,
project: (lat, lng) => [(lng * RAD - minX + pad) * scale, (maxY - mercatorY(lat) + pad) * scale],
};
}
/** A line as an SVG `points` list in the frame, rounded to a tenth of a unit. */
export function linePoints(frame: SketchFrame, points: SketchLine['points']): string {
return points
.map(([lat, lng]) => {
const [x, y] = frame.project(lat, lng);
return `${Math.round(x * 10) / 10},${Math.round(y * 10) / 10}`;
})
.join(' ');
}
@@ -0,0 +1,42 @@
import type { CardMoney } from '@trek/shared';
import { useMemo } from 'react';
import { useTranslation } from '../../../../i18n';
import { useSettingsStore } from '../../../../store/settingsStore';
import { formatClockTime, formatDate, formatMoney } from '../../../../utils/formatters';
import { formatDistance, formatElevation } from '../../../../utils/units';
/**
* How a card writes its numbers, the way the rest of TREK does for this
* user: dates in their language, clock times in their 12 or 24 hour setting,
* money in its currency's own convention, distances and heights in their
* units. A card hands these to its words as `{value}`, never a raw number.
*/
export function useCardFormat() {
const { locale } = useTranslation();
const twelveHour = useSettingsStore((s) => s.settings.time_format === '12h');
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
return useMemo(() => {
const number = new Intl.NumberFormat(locale);
const hours = new Intl.NumberFormat(locale, { style: 'unit', unit: 'hour', unitDisplay: 'narrow' });
const mins = new Intl.NumberFormat(locale, { style: 'unit', unit: 'minute', unitDisplay: 'narrow' });
return {
/** "Sat 12 Oct" from `YYYY-MM-DD`, the year only when it is not this one. */
date: (value: string) => formatDate(value, locale) ?? value,
/** "14:30" or "2:30 PM" from `HH:MM`. */
clock: (value: string) => formatClockTime(value, twelveHour),
money: (value: CardMoney) => formatMoney(value.amount, value.currency, locale),
distance: (km: number) => formatDistance(km, unit),
elevation: (meters: number) => formatElevation(meters, unit),
/** "2h 15min" from minutes, the hours left out below one. */
minutes: (value: number) => {
const total = Math.max(0, Math.round(value));
const h = Math.floor(total / 60);
const m = total % 60;
if (h === 0) return mins.format(m);
return m === 0 ? hours.format(h) : `${hours.format(h)} ${mins.format(m)}`;
},
count: (value: number) => number.format(value),
};
}, [locale, twelveHour, unit]);
}
@@ -0,0 +1,47 @@
import { useCallback, useEffect } from 'react';
import { useAssistantStore } from '../../../../store/assistantStore';
import { useAssistantView } from '../../assistantViewContext';
/** A spot a card points at: its own key, where it is and its name; `placeId` when it is a place of the trip. */
interface CardPoint {
ref: string;
lat: number;
lng: number;
name: string;
placeId?: number;
}
/**
* A card's part of the map: while the pointer rests on a row its spot shows
* as a pin (a place of the trip pulses its own pin), and a click moves the
* map there, after the chat stepped aside where it covers the map (the
* phone). The pin goes when the card does.
*/
export function useCardPoint() {
const { tripId, onRevealMap } = useAssistantView();
const requestCamera = useAssistantStore((s) => s.requestCamera);
const setPreviewPin = useAssistantStore((s) => s.setPreviewPin);
// Stable, so a card can take the pin away when it goes.
const hover = useCallback(
(point: CardPoint | null) => {
if (point) setPreviewPin({ tripId, ...point });
else setPreviewPin(null);
},
[tripId, setPreviewPin]
);
/** Shows the spot on the map; on the phone the chat steps aside first. */
const show = useCallback(
(point: { lat: number; lng: number }) => {
onRevealMap?.();
requestCamera(tripId, [[point.lat, point.lng]], 'pan', 'user');
},
[tripId, onRevealMap, requestCamera]
);
useEffect(() => () => setPreviewPin(null), [setPreviewPin]);
return { hover, show };
}
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/** How far the strip has scrolled from either end, with the reading direction in mind. */
function edgesOf(el: HTMLElement): { start: boolean; end: boolean } {
// A right-to-left strip scrolls into negative numbers; the distance from the start is the same either way.
const from = Math.abs(el.scrollLeft);
return { start: from > 4, end: from + el.clientWidth < el.scrollWidth - 4 };
}
/**
* What a strip of cards needs to scroll well: whether there is more towards
* either end (the edges fade there), measured again when it scrolls, when it
* changes size and when `watch` (its content) changes, and paging by whole
* cards.
*/
export function useCardStrip(watch: unknown) {
const listRef = useRef<HTMLUListElement>(null);
const [fade, setFade] = useState({ start: false, end: false });
const measure = useCallback(() => {
const el = listRef.current;
if (!el) return;
const next = edgesOf(el);
setFade((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));
}, []);
useEffect(() => {
measure();
const el = listRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure, watch]);
/**
* Scrolls by as many whole cards as the view holds, towards the start (-1)
* or the end (1), so the row lands on a card edge instead of fighting its
* snap points half way; smoothly, unless motion is turned down.
*/
const page = (towards: -1 | 1) => {
const el = listRef.current;
const card = el?.firstElementChild as HTMLElement | null;
if (!el || !card) return;
const gap = Number.parseFloat(getComputedStyle(el).columnGap) || 0;
const step = card.offsetWidth + gap;
const cards = Math.max(1, Math.floor((el.clientWidth - 32) / step));
const rtl = getComputedStyle(el).direction === 'rtl';
// Smooth through the strip's own scroll-behavior, which reduced motion turns off.
el.scrollBy((rtl ? -towards : towards) * cards * step, 0);
};
return { listRef, fade, measure, page };
}
@@ -0,0 +1,6 @@
import { useAuthStore } from '../../../../store/authStore';
/** The signed-in user's id, which a card compares its people with; cards never say "me" themselves. */
export function useViewerId(): number | null {
return useAuthStore((s) => s.user?.id ?? null);
}
@@ -0,0 +1,22 @@
import type { PackLook } from '../cardTypes';
/**
* What the road trip brings to the chat's pure look: the verbs, tool names and
* steps it adds, what an applied change of its own opens, and the look of each
* of its kinds. Empty until the road trip cards fill it. A look stays pure,
* icons and literals only: the chat's main chunk reads every look, so whatever
* a look imports ships with it.
*/
export const ROADTRIP_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {},
cards: {
roadtrip: {},
corridor: {},
charging: {},
transit: {},
dayRoute: {},
},
} satisfies PackLook<'roadtrip' | 'corridor' | 'charging' | 'transit' | 'dayRoute'>;
@@ -0,0 +1,16 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
/**
* How the kinds of the road trip draw in the chat; each one draws nothing until
* its cards are built. This file only maps each kind to its view: the
* components live in sibling files, since a file that exports anything besides
* components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const ROADTRIP_VIEWS = {
roadtrip: PENDING_VIEW,
corridor: PENDING_VIEW,
charging: PENDING_VIEW,
transit: PENDING_VIEW,
dayRoute: PENDING_VIEW,
} satisfies { [K in 'roadtrip' | 'corridor' | 'charging' | 'transit' | 'dayRoute']: CardView<K> };
@@ -0,0 +1,23 @@
import { Route } from 'lucide-react';
import type { PackLook } from '../cardTypes';
/**
* What the tours bring to the chat's pure look: the verbs, tool names and steps
* they add, what an applied change of theirs opens, and the look of each of
* their kinds. An applied tour has no way to open yet, as before. A look stays
* pure, icons and literals only: the chat's main chunk reads every look, so
* whatever a look imports ships with it.
*/
export const TOUR_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {
tour: { Icon: Route },
},
cards: {
tourRoute: {},
tourList: {},
},
} satisfies PackLook<'tourRoute' | 'tourList'>;
@@ -0,0 +1,13 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
/**
* How the kinds of the tours draw in the chat; each one draws nothing until its
* cards are built. This file only maps each kind to its view: the components
* live in sibling files, since a file that exports anything besides components
* breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const TOUR_VIEWS = {
tourRoute: PENDING_VIEW,
tourList: PENDING_VIEW,
} satisfies { [K in 'tourRoute' | 'tourList']: CardView<K> };
@@ -0,0 +1,35 @@
import { type Dispatch, type SetStateAction, useState } from 'react';
import type { ChangeItem } from '../assistantParts';
import { viewOf } from './cardViews';
/** The waiting changes whose view leaves them off at first, among those not seen yet. */
function startingOff(items: readonly ChangeItem[], seen: ReadonlySet<string>): string[] {
return items.flatMap((item) =>
item.state === 'pending' && item.card && !seen.has(item.toolCallId) && viewOf(item.card).startsExcluded?.(item.card)
? [item.toolCallId]
: []
);
}
/** The changes whose preview has arrived: from then on the user's choice is theirs alone. */
const arrived = (items: readonly ChangeItem[]) => items.flatMap((item) => (item.preview ? [item.toolCallId] : []));
/**
* The changes the user left off on a card. A change whose view says it
* starts off (an optional extra the user opts into) is left off once, when
* its preview arrives; after that only the user switches it.
*/
export function useChangeExclusions(
items: readonly ChangeItem[]
): [ReadonlySet<string>, Dispatch<SetStateAction<ReadonlySet<string>>>] {
const [excluded, setExcluded] = useState<ReadonlySet<string>>(() => new Set(startingOff(items, new Set())));
const [seen, setSeen] = useState<ReadonlySet<string>>(() => new Set(arrived(items)));
const fresh = arrived(items).filter((id) => !seen.has(id));
if (fresh.length > 0) {
const off = startingOff(items, seen);
setSeen(new Set([...seen, ...fresh]));
if (off.length > 0) setExcluded((prev) => new Set([...prev, ...off]));
}
return [excluded, setExcluded];
}
@@ -0,0 +1,18 @@
import type { PackLook } from '../cardTypes';
/**
* What the vacation planner brings to the chat's pure look: the verbs, tool
* names and steps it adds, what an applied change of its own opens, and the
* look of each of its kinds. Empty until the vacation cards fill it. A look
* stays pure, icons and literals only: the chat's main chunk reads every look,
* so whatever a look imports ships with it.
*/
export const VACAY_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {},
cards: {
vacay: {},
},
} satisfies PackLook<'vacay'>;
@@ -0,0 +1,12 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
/**
* How the kinds of the vacation planner draw in the chat; each one draws
* nothing until its cards are built. This file only maps each kind to its view:
* the components live in sibling files, since a file that exports anything
* besides components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const VACAY_VIEWS = {
vacay: PENDING_VIEW,
} satisfies { [K in 'vacay']: CardView<K> };
@@ -15,6 +15,7 @@ import { safeHexColor } from '../../utils/safeColor';
import { formatDistance } from '../../utils/units';
import { hasUndecided } from './assistantParts';
import { 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 {
@@ -64,7 +65,7 @@ function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng:
*/
export function useAssistantPlaceStrip() {
const { t, locale } = useTranslation();
const { tripId, onRevealMap } = useAssistantView();
const { tripId } = useAssistantView();
const { offline } = useNetworkMode();
const can = useCanDo();
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
@@ -91,8 +92,7 @@ export function useAssistantPlaceStrip() {
})
);
const addFoundPlace = useAssistantStore((s) => s.addFoundPlace);
const requestCamera = useAssistantStore((s) => s.requestCamera);
const setPreviewPin = useAssistantStore((s) => s.setPreviewPin);
const point = useCardPoint();
const [adding, setAdding] = useState<string | null>(null);
const stripDays = useMemo(
@@ -137,18 +137,14 @@ export function useAssistantPlaceStrip() {
};
/** Shows the place on the map; on the phone the chat steps aside first. */
const show = (place: AssistantFoundPlace) => {
onRevealMap?.();
requestCamera(tripId, [[place.lat, place.lng]], 'pan', 'user');
};
const show = point.show;
// Stable, so the strip can take the pin away when it goes.
// The pin goes with the strip: the card point takes it away when it unmounts.
const { hover: hoverPoint } = point;
const hover = useCallback(
(place: AssistantFoundPlace | null) => {
if (place) setPreviewPin({ tripId, ref: place.ref, lat: place.lat, lng: place.lng, name: place.name });
else setPreviewPin(null);
},
[tripId, setPreviewPin]
(place: AssistantFoundPlace | null) =>
hoverPoint(place ? { ref: place.ref, lat: place.lat, lng: place.lng, name: place.name } : null),
[hoverPoint]
);
return {
@@ -232,6 +232,18 @@ export function RoundAction({ label, onClick, children, danger = false }: { labe
)
}
/**
* A traveller's round face: the picture where there is one, else the first
* letter of the name on the accent. Sized and ringed by the caller.
*/
export function TravelerAvatar({ src, name, className = '', style }: { src: string | null; name?: string | null; className?: string; style?: CSSProperties }) {
return (
<span className={`grid flex-none place-items-center overflow-hidden rounded-full bg-accent font-bold text-accent-text ${className}`} style={{ ...fs(9), ...style }}>
{src ? <img src={src} alt="" className="h-full w-full object-cover" /> : name?.[0]?.toUpperCase()}
</span>
)
}
/** Who a booking is for, as the phone shows them: avatar and name in a soft chip. */
export function TravelerChips({ travelers }: { travelers: ReservationTraveler[] }) {
if (travelers.length === 0) return null
@@ -241,9 +253,7 @@ export function TravelerChips({ travelers }: { travelers: ReservationTraveler[]
const src = tv.avatar_url || avatarSrc(tv.avatar)
return (
<span key={tv.user_id} className={`${BOX} flex items-center gap-1.5 rounded-full py-[3px] ps-[3px] pe-2.5`}>
<span className="grid h-5 w-5 flex-none place-items-center overflow-hidden rounded-full bg-accent font-bold text-accent-text" style={fs(9)}>
{src ? <img src={src} alt="" className="h-full w-full object-cover" /> : tv.username?.[0]?.toUpperCase()}
</span>
<TravelerAvatar src={src} name={tv.username} className="h-5 w-5" />
<span className="truncate font-semibold text-content" style={fs(12, 'body')}>{tv.username}</span>
{!!tv.is_guest && <GuestBadge size="xs" customTooltip />}
</span>
@@ -263,9 +273,7 @@ export function TravelerStack({ travelers, max = 3 }: { travelers: ReservationTr
{travelers.slice(0, max).map((tv, i) => {
const src = tv.avatar_url || avatarSrc(tv.avatar)
return (
<span key={tv.user_id} className="grid h-[22px] w-[22px] flex-none place-items-center overflow-hidden rounded-full border-2 border-surface-card bg-accent font-bold text-accent-text" style={{ ...fs(9), marginInlineStart: i ? -7 : 0 }}>
{src ? <img src={src} alt="" className="h-full w-full object-cover" /> : tv.username?.[0]?.toUpperCase()}
</span>
<TravelerAvatar key={tv.user_id} src={src} name={tv.username} className="h-[22px] w-[22px] border-2 border-surface-card" style={{ marginInlineStart: i ? -7 : 0 }} />
)
})}
{travelers.length > max && <span className="ms-1 font-geist font-semibold text-content-faint" style={fs(10.5)}>+{travelers.length - max}</span>}
+1 -1
View File
@@ -105,7 +105,7 @@ export function TimePill({ children, className = '', quiet = false }: { children
return <WhiteBadge className={className} quiet={quiet} icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>{children}</WhiteBadge>
}
type PillTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' | 'accent'
export type PillTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' | 'accent'
// White badges on whatever tint the row has; the tone only colours the words and the icon.
const PILL_TONES: Record<PillTone, string> = {
@@ -1,4 +1,3 @@
import type { AssistantApplied } from '@trek/shared';
import {
ChevronLeft,
Ellipsis,
@@ -12,13 +11,13 @@ import {
Trash2,
} from 'lucide-react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useNavigate, useSearchParams } from 'react-router';
import AssistantCompareSwitch from '../../../../components/Assistant/AssistantCompareSwitch';
import AssistantMemoryDialog from '../../../../components/Assistant/AssistantMemoryDialog';
import AssistantSurfaceLazy from '../../../../components/Assistant/AssistantSurfaceLazy';
import { AI_SETTINGS_PATH } from '../../../../components/Assistant/assistantParts';
import { showOnMapTarget } from '../../../../components/Assistant/assistantShowOnMap';
import type { EntityTarget } from '../../../../components/Assistant/cards/cardTypes';
import { useAssistantHintsSetting } from '../../../../components/Assistant/useAssistantHints';
import { useAssistantLiveStep } from '../../../../components/Assistant/useAssistantLiveStep';
import { useAssistantThread } from '../../../../components/Assistant/useAssistantThread';
@@ -71,10 +70,10 @@ function useBackCloses(open: boolean, onClose: () => void): void {
}, [open]);
}
/** The ways the chat hands the screen to the map. */
/** The ways the chat hands the screen to the map, a tab of the trip or another page. */
interface MapHops {
/** "Show on map" on an applied change: the change is selected there. */
showOnMap: (applied: AssistantApplied) => void;
/** 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;
/** Before the map moves to something the chat points at, such as a found place. */
@@ -84,21 +83,52 @@ interface MapHops {
/**
* Whatever the chat shows on the map, the chat steps aside to a pill and the
* map comes up on the plan tab, then the change or the day is selected there.
* Read through a ref, so the chat does not re-render with every render of the
* trip screen.
* A tab of the trip opens the same way, on its own sub-tab where it has one,
* with what it reads beside it in the URL; another page leaves the trip, and
* 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 {
const navigate = useNavigate();
const [, setSearchParams] = useSearchParams();
const setMinimized = useAssistantStore((s) => s.setMinimized);
const close = useAssistantStore((s) => s.close);
const reveal = () => {
setMinimized(true);
if (shell.trTab !== 'plan') shell.setTrTab('plan');
if (shell.view !== 'map') shell.toggleView();
};
const show = (applied: AssistantApplied) => {
const { placeId, dayId } = showOnMapTarget(applied);
const toTab = ({ tab, sub, params }: Extract<EntityTarget, { kind: 'tab' }>) => {
setMinimized(true);
shell.setTrTab(tab);
if (tab === 'listen' && (sub === 'packing' || sub === 'todo')) shell.setListsTab(sub);
if (tab === 'collab' && (sub === 'chat' || sub === 'notes' || sub === 'links' || sub === 'polls')) {
shell.setCollabTab(sub);
}
const entries = Object.entries(params ?? {});
if (entries.length > 0) {
setSearchParams(
(current) => {
for (const [key, value] of entries) current.set(key, value);
return current;
},
{ replace: true }
);
}
};
const show = (target: EntityTarget) => {
if (target.kind === 'page') {
close();
void navigate(target.path);
return;
}
if (target.kind === 'tab') {
toTab(target);
return;
}
reveal();
if (dayId !== null) planner.handleSelectDay(dayId, false);
if (placeId !== null) planner.setSelectedPlaceId(placeId);
if (target.dayId !== null) planner.handleSelectDay(target.dayId, false);
if (target.placeId !== null) planner.setSelectedPlaceId(target.placeId);
};
const focus = (dayId: number) => {
reveal();
@@ -108,10 +138,10 @@ function useMapHops({ planner, shell }: MTripSheetsProps): MapHops {
useLayoutEffect(() => {
latest.current = { reveal, show, focus };
});
const showOnMap = useCallback((applied: AssistantApplied) => latest.current.show(applied), []);
const open = useCallback((target: EntityTarget) => latest.current.show(target), []);
const focusDay = useCallback((dayId: number) => latest.current.focus(dayId), []);
const revealMap = useCallback(() => latest.current.reveal(), []);
return useMemo(() => ({ showOnMap, focusDay, revealMap }), [showOnMap, focusDay, revealMap]);
return useMemo(() => ({ open, focusDay, revealMap }), [open, focusDay, revealMap]);
}
interface ScreenProps {
@@ -251,7 +281,7 @@ function AssistantScreen({ tripId, tripTitle, hidden, hops }: ScreenProps) {
tripId={tripId}
tripTitle={tripTitle}
variant="fullscreen"
onShowOnMap={hops.showOnMap}
onOpen={hops.open}
onFocusDay={hops.focusDay}
onRevealMap={hops.revealMap}
/>
@@ -0,0 +1,15 @@
import type { PlannerSearchParams } from '../plannerTypes';
import type { useTourWorkspace } from '../useTourWorkspace';
import type { useTripPlanner } from '../useTripPlanner';
/**
* What a part of TREK's deep link into the planner gets: the planner, the
* tour workspace and the URL's query, so a link the chat opened (`?tour=`,
* `?booking=`, `?stop=`) can be taken once and dropped.
*/
export interface DeepLinkProps {
planner: ReturnType<typeof useTripPlanner>;
workspace: ReturnType<typeof useTourWorkspace>;
searchParams: PlannerSearchParams[0];
setSearchParams: PlannerSearchParams[1];
}
@@ -0,0 +1,9 @@
import type { DeepLinkProps } from './deepLinkTypes';
/**
* Where the bookings open a booking the chat points at. Nothing yet: the booking cards
* fill it in, and until then the planner keeps its own links.
*/
export function useBookingDeepLink(_links: DeepLinkProps): void {
// Filled in by the booking cards.
}
@@ -0,0 +1,9 @@
import type { DeepLinkProps } from './deepLinkTypes';
/**
* Where the road trip opens a stop or a day the chat points at. Nothing yet: the road trip cards
* fill it in, and until then the planner keeps its own links.
*/
export function useRoadtripDeepLink(_links: DeepLinkProps): void {
// Filled in by the road trip cards.
}
@@ -0,0 +1,9 @@
import type { DeepLinkProps } from './deepLinkTypes';
/**
* Where the tours open a tour the chat points at (`?tour=`). Nothing yet: the tour cards
* fill it in, and until then the planner keeps its own links.
*/
export function useTourDeepLink(_links: DeepLinkProps): void {
// Filled in by the tour cards.
}
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { useAssistantStore } from '../../store/assistantStore';
import type { Day } from '../../types';
import { useAssistantDeepLink } from './useAssistantDeepLink';
import { useAssistantDeepLink, useAssistantTabLink } from './useAssistantDeepLink';
const day = (id: number, tripId = 5) => ({ id, trip_id: tripId }) as Day;
@@ -59,3 +59,33 @@ describe('useAssistantDeepLink', () => {
expect(useAssistantStore.getState().window.open).toBe(false);
});
});
describe('useAssistantTabLink', () => {
function setupTab(query: string, activeTab: string) {
const searchParams = new URLSearchParams(query);
const setSearchParams = vi.fn((update: (params: URLSearchParams) => URLSearchParams) => update(searchParams));
const selectTab = vi.fn();
renderHook(() =>
useAssistantTabLink({ searchParams, setSearchParams: setSearchParams as never, activeTab, selectTab })
);
return { searchParams, setSearchParams, selectTab };
}
it('FE-PLANNER-ASSISTLINK-005: a ?tab= the chat opens while the planner is open switches the tab, once', () => {
const { searchParams, selectTab } = setupTab('tab=finanzplan&expense=7', 'plan');
expect(selectTab).toHaveBeenCalledWith('finanzplan');
expect(searchParams.has('tab')).toBe(false);
// What the tab reads beside it stays for the tab to take.
expect(searchParams.get('expense')).toBe('7');
});
it('FE-PLANNER-ASSISTLINK-006: the tab on screen is not switched again; a tab it cannot open is left alone', () => {
const same = setupTab('tab=finanzplan', 'finanzplan');
expect(same.selectTab).not.toHaveBeenCalled();
expect(same.searchParams.has('tab')).toBe(false);
const unknown = setupTab('tab=nowhere', 'plan');
expect(unknown.selectTab).not.toHaveBeenCalled();
expect(unknown.setSearchParams).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { isDeepLinkableTripTab } from '../../constants/tripTabs';
import { useAssistantStore } from '../../store/assistantStore';
import type { Day } from '../../types';
import type { PlannerSearchParams } from './plannerTypes';
@@ -50,3 +51,41 @@ export function useAssistantDeepLink({
);
}, [openRequested, requestedDay, known, dayDone, selectDay, setSearchParams]);
}
interface AssistantTabLinkOptions {
searchParams: PlannerSearchParams[0];
setSearchParams: PlannerSearchParams[1];
/** The tab on screen. */
activeTab: string;
/** Switches the planner's tab the way its tab bar does. */
selectTab: (tabId: string) => void;
}
/**
* `?tab=<id>` while the planner is already open: what a card in the chat
* opens in another tab of this trip (`?tab=finanzplan&expense=7`). The
* planner reads the tab once when it opens; this takes a later one live,
* once, and then drops it from the URL. What the tab reads beside it stays
* for that tab to take.
*/
export function useAssistantTabLink({
searchParams,
setSearchParams,
activeTab,
selectTab,
}: AssistantTabLinkOptions): void {
const requested = searchParams.get('tab');
const linkable = requested !== null && isDeepLinkableTripTab(requested);
useEffect(() => {
if (!linkable || requested === null) return;
if (requested !== activeTab) selectTab(requested);
setSearchParams(
(params) => {
params.delete('tab');
return params;
},
{ replace: true }
);
}, [linkable, requested, activeTab, selectTab, setSearchParams]);
}
@@ -0,0 +1,45 @@
/**
* FE-PLANNER-ENTITYLINK-001 to FE-PLANNER-ENTITYLINK-002
*
* The links the chat opens the planner with while it is on screen: another
* tab of the trip switches there and leaves the URL, and the parts' own links
* (tours, bookings, road trip) take nothing yet.
*/
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router';
import { describe, expect, it, vi } from 'vitest';
import { useAssistantEntityLinks } from './useAssistantEntityLinks';
type Options = Parameters<typeof useAssistantEntityLinks>[0];
function setup(path: string) {
const planner = { activeTab: 'plan', handleTabChange: vi.fn() } as unknown as Options['planner'];
const workspace = {} as Options['workspace'];
const wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[path]}>{children}</MemoryRouter>
);
const hook = renderHook(
() => {
useAssistantEntityLinks({ planner, workspace });
return useLocation();
},
{ wrapper }
);
return { planner, hook };
}
describe('useAssistantEntityLinks', () => {
it('FE-PLANNER-ENTITYLINK-001: another tab switches there and leaves the URL, what it reads stays', () => {
const { planner, hook } = setup('/trips/5?tab=finanzplan&expense=7');
expect(planner.handleTabChange).toHaveBeenCalledWith('finanzplan');
expect(hook.result.current.search).toBe('?expense=7');
});
it("FE-PLANNER-ENTITYLINK-002: the parts' own links take nothing yet, and nothing switches without a tab", () => {
const { planner, hook } = setup('/trips/5?tour=12');
expect(planner.handleTabChange).not.toHaveBeenCalled();
expect(hook.result.current.search).toBe('?tour=12');
});
});
@@ -0,0 +1,33 @@
import { useSearchParams } from 'react-router';
import { useBookingDeepLink } from './deepLinks/useBookingDeepLink';
import { useRoadtripDeepLink } from './deepLinks/useRoadtripDeepLink';
import { useTourDeepLink } from './deepLinks/useTourDeepLink';
import { useAssistantTabLink } from './useAssistantDeepLink';
import type { useTourWorkspace } from './useTourWorkspace';
import type { useTripPlanner } from './useTripPlanner';
interface AssistantEntityLinkOptions {
planner: ReturnType<typeof useTripPlanner>;
workspace: ReturnType<typeof useTourWorkspace>;
}
/**
* The links the chat opens the planner with while it is already on screen:
* another tab of the trip (`?tab=`), and what each part of TREK opens in its
* own tab (a tour, a booking, a stop of the road trip). Each is taken once
* and then leaves the URL.
*/
export function useAssistantEntityLinks({ planner, workspace }: AssistantEntityLinkOptions): void {
const [searchParams, setSearchParams] = useSearchParams();
useAssistantTabLink({
searchParams,
setSearchParams,
activeTab: planner.activeTab,
selectTab: planner.handleTabChange,
});
const links = { planner, workspace, searchParams, setSearchParams };
useTourDeepLink(links);
useBookingDeepLink(links);
useRoadtripDeepLink(links);
}
@@ -11,6 +11,7 @@ import { usePlanMapViewport } from '../../hooks/usePlanMapViewport'
import { usePaddleObstacleStore } from '../../store/paddleObstacleStore'
import { useHighlightedRoute } from '../../components/Tours/bike/useHighlightedRoute'
import { useLegVariantLines } from '../../components/Tours/planner/legVariantPaint'
import { useAssistantEntityLinks } from './useAssistantEntityLinks'
import { useMapLocationSearch } from './useMapLocationSearch'
import { usePlannerFocusPoints } from './usePlannerFocusPoints'
import { useTripPlanner } from './useTripPlanner'
@@ -33,8 +34,11 @@ export function useTripPlannerPage() {
const canPlaceEdit = can('place_edit', trip)
const canDayEdit = can('day_edit', trip)
const workspace = useTourWorkspace(planner, canPlaceEdit, canDayEdit)
const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack, trackPaddlePlan, paddleChecklist, skiClassification, tourDetailActions } =
useTourWorkspace(planner, canPlaceEdit, canDayEdit)
workspace
// What the chat opens in another tab of this trip, and in each part's own tab.
useAssistantEntityLinks({ planner, workspace })
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
const { focusPoints, lastPlanMapViewportRef } = usePlannerFocusPoints(activeTab, places, tourPlanner, wandererBrowser)
@@ -1,9 +1,10 @@
/**
* FE-AI-REPO-FOUND-001 to 002
* FE-AI-REPO-FOUND-001 to 004
*
* "+ Day N" on a found place: the repo posts the place's handle and the day,
* parses the message the server answers with, and like every assistant call
* sends nothing while offline.
* "+ Day N" on a found place, and "Plan this" on a card: the repo posts the
* handle (the place, or the call whose card it was), parses the message the
* server answers with, and like every assistant call sends nothing while
* offline.
*/
import type { AssistantAddFoundPlaceResponse } from '@trek/shared';
import { http, HttpResponse } from 'msw';
@@ -52,3 +53,26 @@ describe('assistantRepo.addFoundPlace', () => {
expect(call).not.toHaveBeenCalled();
});
});
describe('assistantRepo.takeProposal', () => {
const PROPOSAL = { locale: 'en', timeZone: 'UTC', toolCallId: 'read-3' };
it('FE-AI-REPO-FOUND-003: posts the call whose card it was, and parses the message it gets back', async () => {
let sent: unknown = null;
server.use(
http.post('/api/trips/4/assistant/threads/7/proposals', async ({ request }) => {
sent = await request.json();
return HttpResponse.json(ANSWER);
})
);
await expect(assistantRepo.takeProposal(4, 7, PROPOSAL)).resolves.toEqual(ANSWER);
expect(sent).toEqual(PROPOSAL);
});
it('FE-AI-REPO-FOUND-004: refuses before a request while offline', async () => {
setForcedOffline(true);
const call = vi.spyOn(assistantApi, 'takeProposal');
await expect(assistantRepo.takeProposal(4, 7, PROPOSAL)).rejects.toBeInstanceOf(OnlineOnlyError);
expect(call).not.toHaveBeenCalled();
});
});
+7
View File
@@ -7,6 +7,7 @@ import {
type AssistantChatRequest,
type AssistantDecisionsRequest,
type AssistantShareRequest,
type AssistantTakeProposalRequest,
} from '@trek/shared';
import { assistantApi } from '../api/assistant';
import { requireOnline } from './onlineOnly';
@@ -78,6 +79,12 @@ export const assistantRepo = {
return assistantAddFoundPlaceResponseSchema.parse(await assistantApi.addFoundPlace(tripId, threadId, body));
},
/** A proposal an answer showed as a card ("Plan this"), held as a change; no model runs for it. */
async takeProposal(tripId: number, threadId: number, body: AssistantTakeProposalRequest) {
requireOnline();
return assistantAddFoundPlaceResponseSchema.parse(await assistantApi.takeProposal(tripId, threadId, body));
},
/**
* An answer posted to the trip chat, as the preview showed it. Online only
* like the rest: a chat message sent later would land out of order between
@@ -1,12 +1,13 @@
/**
* FE-AI-MSG-CARDS-001 to 002
* FE-AI-MSG-CARDS-001 to 005
*
* The places and the weather a step found are stored parts of the answer,
* like a change card: they land in the message being written, and a chunk
* sent again for the same search step replaces its part instead of adding a
* second strip.
* second strip. A tool's card lands the same way, except an applied write's,
* which joins the change that proposed it.
*/
import type { AssistantMessage, AssistantPlaces } from '@trek/shared';
import type { AssistantMessage, AssistantPlaces, TrekCard } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { applyChunk } from './assistantMessages';
@@ -69,4 +70,45 @@ describe('assistantMessages: cards in an answer', () => {
);
expect(messages[0]?.parts.map((p) => p.type)).toEqual(['data-trek-weather']);
});
const ROUTE: TrekCard = { kind: 'tourRoute' };
const LIST: TrekCard = { kind: 'tourList' };
const cardsOf = (message: AssistantMessage | undefined) =>
message?.parts.flatMap((p) => (p.type === 'data-trek-card' ? [p.data] : [])) ?? [];
it('FE-AI-MSG-CARDS-003: a read card lands in the answer, replaced when sent again; a null card is skipped', () => {
let messages = started();
messages = applyChunk(messages, { type: 'data-trek-card', data: { toolCallId: 'r1', card: ROUTE } }, NOW);
messages = applyChunk(messages, { type: 'data-trek-card', data: { toolCallId: 'r1', card: LIST } }, NOW);
messages = applyChunk(messages, { type: 'data-trek-card', data: { toolCallId: 'r2', card: null } }, NOW);
expect(cardsOf(messages[0])).toEqual([{ toolCallId: 'r1', card: LIST }]);
});
it('FE-AI-MSG-CARDS-004: in the decisions stream an applied card joins the message that proposed the change', () => {
const proposing: AssistantMessage = {
id: 'msg-1',
role: 'assistant',
status: 'awaiting_approval',
createdAt: NOW(),
parts: [{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_hike', args: {}, approval: { id: 'w1' } }],
};
let messages = applyChunk([proposing], { type: 'start', messageId: 'msg-2' }, NOW);
messages = applyChunk(messages, { type: 'data-trek-card', data: { toolCallId: 'w1', card: ROUTE } }, NOW);
expect(cardsOf(messages[0])).toEqual([{ toolCallId: 'w1', card: ROUTE }]);
expect(cardsOf(messages[1])).toEqual([]);
});
it('FE-AI-MSG-CARDS-005: a card under a call that holds no change stays in the message being written', () => {
const earlier: AssistantMessage = {
id: 'msg-1',
role: 'assistant',
status: 'complete',
createdAt: NOW(),
parts: [{ type: 'tool-call', toolCallId: 'r1', toolName: 'list_tours', args: {} }],
};
let messages = applyChunk([earlier], { type: 'start', messageId: 'msg-2' }, NOW);
messages = applyChunk(messages, { type: 'data-trek-card', data: { toolCallId: 'r1', card: LIST } }, NOW);
expect(cardsOf(messages[0])).toEqual([]);
expect(cardsOf(messages[1])).toEqual([{ toolCallId: 'r1', card: LIST }]);
});
});
+28
View File
@@ -1,5 +1,6 @@
import type {
AssistantApplied,
AssistantCard,
AssistantDecision,
AssistantMessage,
AssistantMessagePart,
@@ -62,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-card':
return chunk.data.card ? placeCard(messages, now, chunk.data) : messages;
case 'finish':
return withStatus(messages, (m) =>
m.status === 'error' ? m.status : m.parts.some(isAwaitingDecision) ? 'awaiting_approval' : 'complete'
@@ -205,6 +208,31 @@ function upsertCall(
return withCurrent(messages, now, (parts) => [...parts, edit({ type: 'tool-call', toolCallId, toolName, args: {} })]);
}
/**
* Puts a card where it belongs: an applied write's next to the change that
* proposed it (an earlier message, when the decisions stream sends it), a
* read's into the message being written. A repeated card for the same call
* replaces the one before.
*/
function placeCard(messages: AssistantMessage[], now: () => string, data: AssistantCard): AssistantMessage[] {
const card: AssistantMessagePart = { type: 'data-trek-card', data };
const keep = (parts: AssistantMessagePart[]) => [
...parts.filter((p) => !(p.type === 'data-trek-card' && p.data.toolCallId === data.toolCallId)),
card,
];
const at = findChange(messages, data.toolCallId);
if (at < 0) return withCurrent(messages, now, keep);
return messages.map((m, i) => (i === at ? { ...m, parts: keep(m.parts) } : m));
}
/** The message holding the change a call proposed, or -1. */
function findChange(messages: readonly AssistantMessage[], toolCallId: string): number {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]?.parts.some((p) => p.type === 'tool-call' && p.toolCallId === toolCallId && p.approval)) return i;
}
return -1;
}
/** Puts the receipt of an applied change next to its call, so the card and its receipt stay together. */
function placeApplied(messages: AssistantMessage[], now: () => string, data: AssistantApplied): AssistantMessage[] {
const receipt: AssistantMessagePart = { type: 'data-trek-applied', data };
@@ -0,0 +1,102 @@
/**
* FE-AI-STORE-CARDS-001 to FE-AI-STORE-CARDS-004
*
* What the store keeps for the cards: the spot of a route the card and the
* map share, a line a card asks the map to draw, the route the phone peeks
* at, the way picked per block, all gone when the chat closes or changes;
* and "Plan this", which joins the held change to the answer it belongs to.
*/
import type { AssistantMessage } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { assistantRepo } from '../repo/assistantRepo';
import { chatOf, useAssistantStore } from './assistantStore';
const TRIP = 4;
const INITIAL = useAssistantStore.getState();
const store = () => useAssistantStore.getState();
const focus = { distanceMeters: 1200, elevationMeters: 840, lat: 45.1, lng: 7.2, sampleIndex: 12 };
const answer = (parts: AssistantMessage['parts']): AssistantMessage => ({
id: 'msg-1',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-22T08:00:00.000Z',
parts,
});
function seedThread(messages: AssistantMessage[] = []) {
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chatOf(store(), TRIP), threadId: 7, messages } } });
}
beforeEach(() => useAssistantStore.setState(INITIAL, true));
afterEach(() => vi.restoreAllMocks());
describe('assistantStore: cards', () => {
it('FE-AI-STORE-CARDS-001: a route spot changes only when it moves, and a line only when it is another one', () => {
const seen = vi.fn();
const stop = useAssistantStore.subscribe(seen);
store().setRouteFocus({ tripId: TRIP, ref: 'r1', focus, source: 'card' });
store().setRouteFocus({ tripId: TRIP, ref: 'r1', focus: { ...focus }, source: 'card' });
expect(seen).toHaveBeenCalledTimes(1);
store().setRouteFocus({ tripId: TRIP, ref: 'r1', focus: { ...focus, sampleIndex: 13 }, source: 'map' });
expect(store().routeFocus?.source).toBe('map');
const line = { tripId: TRIP, ref: 'day-2', lines: [[[45, 7] as [number, number]]], color: 'var(--accent)' };
store().setRoutePreview(line);
store().setRoutePreview({ ...line });
store().setRoutePreview(null);
store().setRoutePreview(null);
expect(seen).toHaveBeenCalledTimes(4);
expect(store().routePreview).toBeNull();
stop();
});
it('FE-AI-STORE-CARDS-002: the way picked is kept per block of the chat', () => {
seedThread();
store().pick(TRIP, 'block-1', 'r2');
store().pick(TRIP, 'block-2', 'r5');
store().pick(TRIP, 'block-1', 'r3');
expect(chatOf(store(), TRIP).picks).toEqual({ 'block-1': 'r3', 'block-2': 'r5' });
});
it('FE-AI-STORE-CARDS-003: closing, a new chat and another thread leave the map without the cards', async () => {
const fill = () => {
store().setRouteFocus({ tripId: TRIP, ref: 'r1', focus, source: 'card' });
store().setRoutePreview({ tripId: TRIP, ref: 'day-2', lines: [], color: 'var(--accent)' });
store().setRoutePeek({ tripId: TRIP, toolCallId: 'r1' });
};
const empty = () => ({ focus: store().routeFocus, line: store().routePreview, peek: store().routePeek });
const none = { focus: null, line: null, peek: null };
fill();
expect(store().routePeek).toEqual({ tripId: TRIP, toolCallId: 'r1' });
store().close();
expect(empty()).toEqual(none);
fill();
store().newThread(TRIP);
expect(empty()).toEqual(none);
fill();
vi.spyOn(assistantRepo, 'thread').mockResolvedValueOnce({ messages: [] } as never);
const opening = store().openThread(TRIP, 9);
expect(empty()).toEqual(none);
await opening;
});
it('FE-AI-STORE-CARDS-004: "Plan this" joins the held change to its answer, and says why when it cannot', async () => {
seedThread([answer([{ type: 'text', text: 'Three ways up.' }])]);
const held = answer([
{ type: 'text', text: 'Three ways up.' },
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_hike', args: {}, approval: { id: 'w1' } },
]);
const take = vi.spyOn(assistantRepo, 'takeProposal').mockResolvedValueOnce({ message: held });
await store().takeProposal(TRIP, 'r1');
expect(take).toHaveBeenCalledWith(TRIP, 7, expect.objectContaining({ toolCallId: 'r1' }));
expect(chatOf(store(), TRIP).messages).toEqual([held]);
vi.spyOn(assistantRepo, 'takeProposal').mockRejectedValueOnce({ response: { status: 429 } });
await store().takeProposal(TRIP, 'r1');
expect(chatOf(store(), TRIP).error).toBe('too_many_requests');
});
});
+94 -18
View File
@@ -20,6 +20,7 @@ import { assistantRepo } from '../repo/assistantRepo';
import { OnlineOnlyError } from '../repo/onlineOnly';
import type { Assignment } from '../types';
import { randomId } from '../utils/randomId';
import type { RouteProfileFocus } from '../utils/routeGeometry';
import {
blockDays,
blockKeyOf,
@@ -74,6 +75,8 @@ interface AssistantTripChat {
compare: AssistantCompare | null;
/** The days, places and bookings named with @ in the draft. */
mentions: AssistantMentionItem[];
/** The way picked among the ways a block of cards offers, by block. */
picks: Record<string, string>;
}
/** A mention as the composer picked it, with the label it wrote into the text. */
@@ -122,8 +125,17 @@ interface AssistantState {
mapLink: { tripId: number; hoveredId: string | null; source: AssistantMapSource } | null;
/** The latest request to move the map; `seq` tells a new one from the same points asked again. */
camera: { tripId: number; seq: number; points: LatLng[]; mode: 'fit' | 'pan'; why: 'user' | 'auto' } | null;
/** A found place the pointer rests on, drawn on the map until it leaves. */
preview: { tripId: number; ref: string; lat: number; lng: number; name: string } | null;
/**
* A spot a card points at (a found place, a row), drawn on the map until
* the pointer leaves; a place of the trip pulses its own pin instead.
*/
preview: { tripId: number; ref: string; lat: number; lng: number; name: string; placeId?: number } | null;
/** The point of a route a card and the map both show, from the card's profile or from the line on the map. */
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;
/** 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. */
windowRect: WindowRect | null;
/** The part of the planner's map on screen, sent with a question so "here" means it. */
@@ -173,6 +185,14 @@ interface AssistantState {
setPreviewPin: (preview: AssistantState['preview']) => void;
setWindowRect: (rect: WindowRect | null) => void;
setMapView: (mapView: AssistantState['mapView']) => void;
setRouteFocus: (routeFocus: AssistantState['routeFocus']) => void;
setRoutePreview: (routePreview: AssistantState['routePreview']) => void;
setRoutePeek: (routePeek: AssistantState['routePeek']) => void;
/** Picks one of the ways a block of cards offers. */
pick: (tripId: number, blockKey: string, toolCallId: string) => void;
/** Takes a proposal an answer showed as a card ("Plan this"); the server holds it as a change to decide. */
takeProposal: (tripId: number, toolCallId: string) => Promise<void>;
}
const NO_LIVE: AssistantTripChat['live'] = { activity: null };
@@ -192,8 +212,12 @@ const EMPTY_CHAT: AssistantTripChat = {
undone: {},
compare: null,
mentions: [],
picks: {},
};
/** What the map shows for the cards of a chat, cleared when the chat closes or changes. */
const NO_ROUTE = { routeFocus: null, routePreview: null, routePeek: null } as const;
/** A trip's chat as the store holds it, or the empty one; stable, so a selector can return it. */
export function chatOf(state: Pick<AssistantState, 'byTrip'>, tripId: number): AssistantTripChat {
return state.byTrip[tripId] ?? EMPTY_CHAT;
@@ -462,6 +486,7 @@ function shellActions(ctx: Ctx) {
window: { open: false, minimized: false },
mapLink: null,
preview: null,
...NO_ROUTE,
byTrip: backToAfter(s.byTrip),
})),
// Closed or minimized opens it; open closes it.
@@ -487,6 +512,7 @@ function threadActions(ctx: Ctx) {
const openThread = async (tripId: number, threadId: number) => {
if (chat(ctx, tripId).isRunning) return;
ctx.set(NO_ROUTE);
patch(ctx, tripId, { threadId, loading: true, error: null, unsent: null });
try {
const { messages } = await assistantRepo.thread(tripId, threadId);
@@ -510,6 +536,7 @@ function threadActions(ctx: Ctx) {
const newThread = (tripId: number) => {
chat(ctx, tripId).abort?.abort();
ctx.set(NO_ROUTE);
// The block a comparison belonged to leaves the screen with its thread.
patch(ctx, tripId, {
threadId: null,
@@ -619,25 +646,43 @@ function compareActions(ctx: Ctx) {
const points = [...dayPoints(compare.snapshot, days), ...dayPoints(useTripStore.getState().assignments, days)];
requestCameraOn(ctx, tripId, points, 'fit', 'user');
},
addFoundPlace: async (tripId: number, ref: string, dayId: number) => {
const threadId = chat(ctx, tripId).threadId;
if (chat(ctx, tripId).isRunning || threadId === null) return;
try {
const { message } = await assistantRepo.addFoundPlace(tripId, threadId, { ...clientContext(), ref, dayId });
const c = chat(ctx, tripId);
// The thread may have changed while the server answered. The server sends back the whole answer the
// card joined, under that answer's id, so it replaces the message it already holds.
if (c.threadId !== threadId) return;
const known = c.messages.some((m) => m.id === message.id);
const messages = known ? c.messages.map((m) => (m.id === message.id ? message : m)) : [...c.messages, message];
patch(ctx, tripId, { messages, error: null });
} catch (err) {
patch(ctx, tripId, { error: errorCodeOf(err) });
}
},
addFoundPlace: (tripId: number, ref: string, dayId: number) =>
holdWithoutTurn(ctx, tripId, (threadId) =>
assistantRepo.addFoundPlace(tripId, threadId, { ...clientContext(), ref, dayId })
),
takeProposal: (tripId: number, toolCallId: string) =>
holdWithoutTurn(ctx, tripId, (threadId) =>
assistantRepo.takeProposal(tripId, threadId, { ...clientContext(), toolCallId })
),
};
}
/**
* A change the server holds without a model turn (a found place for a day, a
* proposal a card showed), joined to the answer it belongs to. The server
* sends back that whole answer under its id, so it replaces the message the
* chat already holds.
*/
async function holdWithoutTurn(
ctx: Ctx,
tripId: number,
hold: (threadId: number) => Promise<{ message: AssistantMessage }>
): Promise<void> {
const threadId = chat(ctx, tripId).threadId;
if (chat(ctx, tripId).isRunning || threadId === null) return;
try {
const { message } = await hold(threadId);
const c = chat(ctx, tripId);
// The thread may have changed while the server answered.
if (c.threadId !== threadId) return;
const known = c.messages.some((m) => m.id === message.id);
const messages = known ? c.messages.map((m) => (m.id === message.id ? message : m)) : [...c.messages, message];
patch(ctx, tripId, { messages, error: null });
} catch (err) {
patch(ctx, tripId, { error: errorCodeOf(err) });
}
}
function requestCameraOn(ctx: Ctx, tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') {
if (points.length === 0) return;
const seq = (ctx.get().camera?.seq ?? 0) + 1;
@@ -665,6 +710,35 @@ function mapActions(ctx: Ctx) {
};
}
/** Whether two route focuses point at the same spot of the same route from the same side. */
function sameFocus(a: AssistantState['routeFocus'], b: AssistantState['routeFocus']): boolean {
if (a === null || b === null) return a === b;
return (
a.tripId === b.tripId &&
a.ref === b.ref &&
a.source === b.source &&
a.focus.sampleIndex === b.focus.sampleIndex &&
a.focus.distanceMeters === b.focus.distanceMeters
);
}
function routeActions(ctx: Ctx) {
return {
// Scrubbing repeats the same spot; only a change reaches the subscribers.
setRouteFocus: (routeFocus: AssistantState['routeFocus']) => {
if (!sameFocus(ctx.get().routeFocus, routeFocus)) ctx.set({ routeFocus });
},
setRoutePreview: (routePreview: AssistantState['routePreview']) => {
const current = ctx.get().routePreview;
if (current?.tripId === routePreview?.tripId && current?.ref === routePreview?.ref) return;
ctx.set({ routePreview });
},
setRoutePeek: (routePeek: AssistantState['routePeek']) => ctx.set({ routePeek }),
pick: (tripId: number, blockKey: string, toolCallId: string) =>
patch(ctx, tripId, { picks: { ...chat(ctx, tripId).picks, [blockKey]: toolCallId } }),
};
}
function turnActions(ctx: Ctx) {
const retry = async (tripId: number) => {
const c = chat(ctx, tripId);
@@ -728,10 +802,12 @@ export const useAssistantStore = create<AssistantState>((set, get) => {
preview: null,
windowRect: null,
mapView: null,
...NO_ROUTE,
...shellActions(ctx),
...threadActions(ctx),
...turnActions(ctx),
...compareActions(ctx),
...mapActions(ctx),
...routeActions(ctx),
};
});
+41
View File
@@ -0,0 +1,41 @@
import type { TrekCard } from '@trek/shared';
/**
* What an assistant card asks the plan's map to draw, in the shapes the map
* layers read. These sit below the views, so the store's map model and the
* planner's map hooks share them with the cards' looks
* (components/Assistant/cards/cardTypes.ts) without importing a view.
*/
/** Which lines of a card the plan's map draws: a waiting change, an answer to look at, the way it was before. */
export type RouteUse = 'pending' | 'look' | 'before';
/** A line a card asks the plan's map to draw beside the plan: points as `[lat, lng]`, colours as CSS values. */
export interface RouteLineSpec {
id: string;
lines: [number, number][][];
color: string;
casing?: string;
dashed?: boolean;
dim?: boolean;
}
/** A spot a card asks the plan's map to mark: a pin for a stop, a small point for a via. */
export interface GhostPoint {
id: string;
lat: number;
lng: number;
name: string;
variant?: 'pin' | 'point';
}
/**
* The map's side of every card's look, for the card in hand. A store may not
* import a view, so the store's map model takes this as a parameter and the
* planner's map hook hands it `CARD_MAP_LOOK` from the looks.
*/
export interface CardMapLook {
lines(card: TrekCard, use: RouteUse): RouteLineSpec[];
ghosts(card: TrekCard): GhostPoint[];
hidesEffectGhost(card: TrekCard): boolean;
}