diff --git a/client/scripts/i18n-keys.mjs b/client/scripts/i18n-keys.mjs index 57aab93fe..b991a9c00 100644 --- a/client/scripts/i18n-keys.mjs +++ b/client/scripts/i18n-keys.mjs @@ -139,13 +139,6 @@ export const DYNAMIC_ALLOWED = [ 'memories.* key in en', })) ), - { - file: 'components/Assistant/AssistantChangeCard.tsx', - template: 'assistant.tool.${tool}', - because: - 'tool is the MCP tool a change card is for; verbLabel shows assistant.tool. only where en names ' + - 'that tool, and the generic add, change or remove verb for every other tool', - }, ]; const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/; diff --git a/client/src/api/assistant.ts b/client/src/api/assistant.ts index e44534315..57ad95427 100644 --- a/client/src/api/assistant.ts +++ b/client/src/api/assistant.ts @@ -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 => 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 => + 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, diff --git a/client/src/components/Assistant/AssistantChangeCard.test.tsx b/client/src/components/Assistant/AssistantChangeCard.test.tsx index 27db4bcd1..fb262ce4c 100644 --- a/client/src/components/Assistant/AssistantChangeCard.test.tsx +++ b/client/src/components/Assistant/AssistantChangeCard.test.tsx @@ -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 = {}) { @@ -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(); diff --git a/client/src/components/Assistant/AssistantChangeCard.tsx b/client/src/components/Assistant/AssistantChangeCard.tsx index 5ed5e28ed..ddc631b81 100644 --- a/client/src/components/Assistant/AssistantChangeCard.tsx +++ b/client/src/components/Assistant/AssistantChangeCard.tsx @@ -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 = { - 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 = { - 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, { 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