diff --git a/client/src/components/Admin/addons/useLlmParsingConfig.test.ts b/client/src/components/Admin/addons/useLlmParsingConfig.test.ts index 11d2029df..1b8dc8b2e 100644 --- a/client/src/components/Admin/addons/useLlmParsingConfig.test.ts +++ b/client/src/components/Admin/addons/useLlmParsingConfig.test.ts @@ -161,7 +161,8 @@ describe('useLlmParsingConfig', () => { await act(async () => result.current.setVision('off')); act(() => result.current.setProvider('anthropic')); await act(() => result.current.save()); - expect(vi.mocked(adminApi.updateAddon).mock.calls.at(-1)?.[1]).toEqual({ + const calls = vi.mocked(adminApi.updateAddon).mock.calls; + expect(calls[calls.length - 1]?.[1]).toEqual({ config: { provider: 'anthropic', model: '', baseUrl: '', apiKey: '', vision: 'off' }, }); }); diff --git a/client/src/components/Assistant/AssistantChangeCard.test.tsx b/client/src/components/Assistant/AssistantChangeCard.test.tsx index db833721c..b3dd0f9af 100644 --- a/client/src/components/Assistant/AssistantChangeCard.test.tsx +++ b/client/src/components/Assistant/AssistantChangeCard.test.tsx @@ -166,7 +166,7 @@ describe('AssistantChangeCard: the receipt', () => { const onShowOnMap = vi.fn(); renderCard([receipt('c1', '2026-10-21T08:15:00.000Z')], { onShowOnMap }); - expect(screen.getByText('Applied')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Applied' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Show on map' })); expect(onShowOnMap).toHaveBeenCalledWith(expect.objectContaining({ toolCallId: 'c1', dayId: 3 })); expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument(); @@ -188,7 +188,7 @@ describe('AssistantChangeCard: the receipt', () => { fireEvent.click(screen.getByRole('button', { name: 'Undo' })); }); expect(undo).toHaveBeenCalledWith(TRIP, 'c1'); - expect(screen.getByText('Undone')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Undone' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull(); first.unmount(); @@ -204,7 +204,7 @@ describe('AssistantChangeCard: the receipt', () => { renderCard([receipt('c1', null), { ...pending('c2', 'Ryoan-ji'), state: 'discarded' }]); expect(screen.queryByRole('button', { name: 'Show on map' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull(); - expect(screen.getByText('Discarded')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Discarded' })).toBeInTheDocument(); }); }); diff --git a/client/src/components/Assistant/AssistantChangeCard.tsx b/client/src/components/Assistant/AssistantChangeCard.tsx index 007e16b88..20382c138 100644 --- a/client/src/components/Assistant/AssistantChangeCard.tsx +++ b/client/src/components/Assistant/AssistantChangeCard.tsx @@ -1,6 +1,5 @@ -import type { AssistantChangePreview, AssistantChangeRow, AssistantChangeVerbKey } from '@trek/shared'; +import type { AssistantApplied, AssistantChangePreview, AssistantChangeVerbKey } from '@trek/shared'; import { - ArrowRight, CalendarArrowUp, CalendarMinus, CalendarPlus, @@ -11,6 +10,7 @@ import { Luggage, MapPin, MapPinPlus, + NotebookPen, PencilLine, StickyNote, Ticket, @@ -27,10 +27,11 @@ import { useTripStore } from '../../store/tripStore'; import ToggleSwitch from '../Settings/ToggleSwitch'; import { useToast } from '../shared/Toast'; import { Tooltip } from '../shared/Tooltip'; +import AssistantChangeFields from './AssistantChangeFields'; import AssistantCompareSwitch from './AssistantCompareSwitch'; import AssistantDayBand from './AssistantDayBand'; import AssistantWeekOverview from './AssistantWeekOverview'; -import { CHANGE_LABELS, type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts'; +import { type ChangeItem, type ChangeState, undoLeftMs } from './assistantParts'; import { useAssistantView } from './assistantViewContext'; import { changePoints, type DayChange } from './dayAfterModel'; import { useChangeHover } from './useChangeHover'; @@ -78,8 +79,10 @@ const VERB_LOOK: Record{t('assistant.change.empty')}; - return ( - - - {t(CHANGE_LABELS[row.labelKey].labelKey)} - - {row.op !== 'add' && ( - <> - {row.before || empty} - - ); -} - -function ChangeFields({ rows }: { rows: AssistantChangeRow[] }) { - if (rows.length === 0) return null; - return ( -
- {rows.map((row, i) => ( - - ))} -
- ); -} - /** The time now, ticking once more when the next undo window closes. */ function useUndoClock(items: ChangeItem[]): number { const [now, setNow] = useState(() => Date.now()); @@ -192,6 +164,9 @@ function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet = new Set(['place', 'assignment', 'day_note', 'reservation']); + function Receipt({ item, undone, @@ -209,6 +184,7 @@ function Receipt({ 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 ( @@ -217,7 +193,7 @@ function Receipt({
- {applied && onShowOnMap && ( + {applied && mappable && onShowOnMap && ( + {open && ( +
+ {rest.map((row, i) => ( + + ))} +
+ )} + + )} + + ); +} diff --git a/client/src/components/Assistant/AssistantComposer.test.tsx b/client/src/components/Assistant/AssistantComposer.test.tsx index e195b1efe..384a12c09 100644 --- a/client/src/components/Assistant/AssistantComposer.test.tsx +++ b/client/src/components/Assistant/AssistantComposer.test.tsx @@ -51,7 +51,7 @@ function seed(tripInPlanner = TRIP) { async function input(): Promise { render(); - return (await screen.findByPlaceholderText('Ask anything about Paris')) as HTMLTextAreaElement; + return (await screen.findByRole('textbox', { name: 'Ask anything about Paris' })) as HTMLTextAreaElement; } const mentions = () => chatOf(useAssistantStore.getState(), TRIP).mentions; diff --git a/client/src/components/Assistant/AssistantDayBand.tsx b/client/src/components/Assistant/AssistantDayBand.tsx index df1a5f791..3d94e6b9b 100644 --- a/client/src/components/Assistant/AssistantDayBand.tsx +++ b/client/src/components/Assistant/AssistantDayBand.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, CircleCheck, TriangleAlert } from 'lucide-react'; +import { ChevronDown, CircleCheck, Minus, Plus, TriangleAlert } from 'lucide-react'; import { useMemo, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; @@ -20,8 +20,9 @@ import { useChangeHover } from './useChangeHover'; /** * The days a waiting card touches, as they would read once it ran: per day its - * date and its stops in order as small named chips, so a glance shows where the - * suggestion lands among the rest. The details (times, the way between stops, + * date and its stops in order. What stays reads as quiet text; only what the + * card adds is a filled pill and what it takes away is struck, so a glance + * finds the suggestion among the rest. The details (times, the way between stops, * free time, a clash) stay folded away until asked for. Two days at most; the * rest are counted. Shown only while the planner holds this trip, since the day * plans come from it. @@ -33,26 +34,33 @@ const MAX_DAYS = 2; const MAX_CHIPS = 6; const CHIP: Record = { - kept: 'border-transparent bg-surface-secondary text-content-muted', - new: 'border-dashed border-success bg-success-soft text-content', - removed: 'border-transparent text-content-faint line-through', + kept: 'px-0.5 text-content-muted', + new: 'rounded-full bg-accent px-2 py-0.5 font-medium text-accent-text shadow-sm', + removed: 'rounded-full px-1.5 py-0.5 text-content-faint line-through', }; /** One stop of the day, named; the change it belongs to lights up with it. */ function StopChip({ stop }: { stop: TimelineStop }) { const { tripId } = useAssistantView(); const { lit, handlers } = useChangeHover(tripId, stop.toolCallId); - const clash = stop.lead?.overlapsWith ? 'border-danger' : stopIssue(stop) ? 'border-warning' : ''; + const issue = stop.lead?.overlapsWith ? 'ring-2 ring-danger' : stopIssue(stop) ? 'ring-2 ring-warning' : ''; + const Mark = stop.status === 'new' ? Plus : stop.status === 'removed' ? Minus : null; return ( + {Mark && ); } +/** The thin rule between two stops, read as "then". */ +function Then() { + return