From 9f6f62c6aee8f5559d690c5c625ef3236f102b50 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 23 Oct 2026 14:03:37 +0200 Subject: [PATCH] feat(assistant): a proposed route shows on the Tours tab's map too, and its ways leave the map once one is planned - The Tours tab's map draws the routes the chat's cards show and follows the assistant's camera, as the plan's map does; "On map" there stays on it and only a tab without a map switches to the plan's. - Once one of an answer's ways is taken into the trip, the answer draws none of them: the taken one stands as its change, a discarded take brings them back. --- .../components/Assistant/AssistantHost.tsx | 11 +++++--- client/src/pages/TripPlannerPage.tsx | 1 + .../pages/tripPlanner/TourPlannerMapStage.tsx | 8 ++++++ client/src/store/assistantRouteModel.test.ts | 16 ++++++++++- client/src/store/assistantRouteModel.ts | 27 ++++++++++++++++++- 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/client/src/components/Assistant/AssistantHost.tsx b/client/src/components/Assistant/AssistantHost.tsx index 99771d4f9..0a6989484 100644 --- a/client/src/components/Assistant/AssistantHost.tsx +++ b/client/src/components/Assistant/AssistantHost.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react'; import { useLocation, useMatch, useNavigate } from 'react-router'; import { rememberListsSubTab } from '../../hooks/useListsSubTab'; +import { useAssistantStore } from '../../store/assistantStore'; import { useTripStore } from '../../store/tripStore'; import AssistantWindow from './AssistantWindow'; import { plannerSearchOf } from './assistantShowOnMap'; @@ -22,7 +23,7 @@ function useRouteTripId(): number | null { * notification opens a place, a tab of the trip opens as `?tab=` (a list's * own tab remembered first), and another page is simply navigated to. A day * picked in the chat (its weather) selects that day the same way, and "On - * map" on a card brings the planner's map back first. + * map" on a card brings the planner's map back first from a tab without one. */ export default function AssistantHost() { const navigate = useNavigate(); @@ -54,8 +55,12 @@ export default function AssistantHost() { [navigate, openPlanner, tripId] ); const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]); - // "On map" from another tab of the planner (Tours, Costs, ...) first brings the map back. - const revealMap = useCallback(() => openPlanner('?tab=plan'), [openPlanner]); + // "On map" from a tab without a map (Costs, Lists, ...) first brings the plan's map back; the Tours tab + // shows the route on its own map. A map on screen tells the assistant its view, which is how it is known. + const revealMap = useCallback(() => { + if (useAssistantStore.getState().mapView?.tripId === tripId) return; + openPlanner('?tab=plan'); + }, [openPlanner, tripId]); return ; } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 68962b82f..b7c2f0b62 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -627,6 +627,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { tourPlanner={tourPlanner} tourMap={tourMap} locationSearch={locationSearch} canPlaceEdit={canPlaceEdit} leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx} onViewportChange={wandererBrowser.onViewportChange} paddleChecklist={pageState.paddleChecklist} + assistantRoutes={assistantMap} />
diff --git a/client/src/store/assistantRouteModel.test.ts b/client/src/store/assistantRouteModel.test.ts index 2d08d4572..aeaa7da2f 100644 --- a/client/src/store/assistantRouteModel.test.ts +++ b/client/src/store/assistantRouteModel.test.ts @@ -1,5 +1,5 @@ /** - * FE-AI-ROUTE-001 to FE-AI-ROUTE-005 + * FE-AI-ROUTE-001 to FE-AI-ROUTE-006 * * Which routes of the chat's cards the plan's map draws, and how: changes * that still stand on the map with the way they replace, the cards of the @@ -127,6 +127,20 @@ describe('assistantRouteModel', () => { expect(refs[1]?.card).toBe(SAVED); }); + it('FE-AI-ROUTE-006: once one of the ways is taken into the trip, the answer draws none of them; a discarded take brings them back', () => { + const latest = message([...read('call_1', HIKE), ...read('call_2', GENTLE)], 'msg-1'); + const take = (approval: Approval) => { + const parts = held('t1', PENDING, approval); + const change = parts[1] as Extract; + change.data = { ...change.data, takenFrom: 'call_2' }; + return message(parts, 'msg-2'); + }; + const waiting = routeRefs([latest, take({})], {}, NOBODY, LOOK, AS_WAYS); + expect(waiting.map((ref) => `${ref.toolCallId}:${ref.use}`)).toEqual(['t1:before', 't1:pending']); + const discarded = routeRefs([latest, take({ approved: false })], {}, NOBODY, LOOK, AS_WAYS); + expect(discarded.map((ref) => ref.toolCallId)).toEqual(['call_1', 'call_2']); + }); + it("FE-AI-ROUTE-003: lines lie in order (replaced way, others, the chosen way, the lit one) in their ways' colours", () => { const previous = { line: flat(HIKE), distanceM: 8000, ascentM: null, minutes: null }; const messages = [ diff --git a/client/src/store/assistantRouteModel.ts b/client/src/store/assistantRouteModel.ts index c51950505..43ebd996a 100644 --- a/client/src/store/assistantRouteModel.ts +++ b/client/src/store/assistantRouteModel.ts @@ -101,7 +101,8 @@ function standsOnMap(part: ToolCallPart, receipt: AssistantApplied | undefined, /** * The cards of the latest answer that draws any, laid out as the chat lays * them out: a group of several is a choice of ways, each with its letter, the - * chosen one (by `picks`, else the first) picked. Older answers drop. + * chosen one (by `picks`, else the first) picked. Older answers drop, and so + * do the ways of a group once one of them was taken into the trip. */ function lookRefs( messages: readonly AssistantMessage[], @@ -111,6 +112,7 @@ function lookRefs( layout: AnswerLayout ): RouteRef[] { const chosenIds = new Set(Object.values(picks)); + const taken = takenProposals(messages); for (let index = messages.length - 1; index >= 0; index--) { const answers = (messages[index]?.parts ?? []).flatMap((part): RouteCardEntry[] => part.type === 'data-trek-card' && part.data.card && !changeIds.has(part.data.toolCallId) @@ -121,6 +123,7 @@ function lookRefs( const drawn = new Set(lined.map((entry) => entry.toolCallId)); if (drawn.size === 0) continue; return layout(answers).flatMap((group) => { + if (group.some((entry) => taken.has(entry.toolCallId))) return []; const ways = group.length >= 2; const chosen = group.find((entry) => chosenIds.has(entry.toolCallId)) ?? group[0]; return group.flatMap((entry, position): RouteRef[] => { @@ -133,6 +136,28 @@ function lookRefs( return []; } +/** + * The proposals taken into the trip ("Plan this") and not discarded since: + * each stands as its change from then on, so its answer's ways leave the map. + */ +function takenProposals(messages: readonly AssistantMessage[]): Set { + const dropped = new Set(); + for (const message of messages) { + for (const part of message.parts) { + if (part.type !== 'tool-call' || !part.approval) continue; + if (part.approval.approved === false || part.approval.resolution) dropped.add(part.toolCallId); + } + } + const taken = new Set(); + for (const message of messages) { + for (const part of message.parts) { + if (part.type !== 'data-trek-change' || !part.data.takenFrom) continue; + if (!dropped.has(part.data.toolCallId)) taken.add(part.data.takenFrom); + } + } + return taken; +} + /** * Every card of the chat the map draws now, in the part it plays: the * changes that still stand on the map (each with the way it replaces), then