mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user