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.
This commit is contained in:
Maurice
2026-10-23 14:03:37 +02:00
parent a70ff750a6
commit 9f6f62c6ae
5 changed files with 58 additions and 5 deletions
@@ -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 <AssistantWindow trip={trip} onOpen={open} onFocusDay={focusDay} onRevealMap={revealMap} />;
}
+1
View File
@@ -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}
/>
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<PanelTab hidden={leftHidden} label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')} onToggle={toggleLeft} />
@@ -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<TourPlannerMapStageProps>): 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}
/>
)}
</div>
+15 -1
View File
@@ -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<AssistantMessagePart, { type: 'data-trek-change' }>;
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 = [
+26 -1
View File
@@ -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<string> {
const dropped = new Set<string>();
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<string>();
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