mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(assistant): a card framework every addon package plugs into
This commit is contained in:
@@ -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>}
|
||||
|
||||
@@ -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,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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user