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/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx
index c031ed3a0..fe5f7ec78 100644
--- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx
+++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx
@@ -8,6 +8,7 @@ import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressG
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
import { MapUndoHint } from '../../components/Map/MapUndoHint';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
+import type { AssistantMapProps } from '../../components/Map/mapViewContract';
import LocationSelect from '../../components/Planner/LocationSelect';
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
@@ -74,6 +75,11 @@ export interface TourPlannerMapStageProps
onViewportChange?: (bounds: FerrataBbox) => void;
/** The open paddle tour's checklist (PAD-06): its part for the picked object rides in the object's map card. */
paddleChecklist?: PageState['paddleChecklist'];
+ /** The routes the assistant's cards show and its camera, as on the plan's map, so a proposed hike shows here too. */
+ assistantRoutes?: Pick<
+ AssistantMapProps,
+ 'assistantCamera' | 'assistantRouteLines' | 'onAssistantRouteHover' | 'onAssistantRoutePoint'
+ >;
}
/** What the map reads of the tour on screen: a GPX file viewed as it came in has its analysis and no points. */
@@ -104,6 +110,7 @@ export function TourPlannerMapStage({
rightPanelPx,
onViewportChange,
paddleChecklist,
+ assistantRoutes,
}: Readonly
): ReactElement {
const [terrain3d, setTerrain3d] = useState(false);
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
@@ -285,6 +292,7 @@ export function TourPlannerMapStage({
onViewportChange={viewportChanged}
placeTool={placeTool}
{...poiMarkers}
+ {...assistantRoutes}
/>
)}
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