> = {}) {
+ const message: AssistantMessage = {
+ id: 'msg-1',
+ role: 'assistant',
+ parts,
+ status: 'awaiting_approval',
+ createdAt: '2026-10-21T08:00:00.000Z',
+ };
+ useAssistantStore.setState((s) => ({ byTrip: { [TRIP]: { ...chatOf(s, TRIP), messages: [message], ...over } } }));
+}
+
+interface Props {
+ isMobile?: boolean;
+ mapLocked?: boolean;
+ mapPlaces?: { id: number }[];
+}
+
+function link(selectDay = vi.fn(), initial: Props = {}) {
+ return renderHook(
+ (props: Props) =>
+ useAssistantMapLink({
+ tripId: TRIP,
+ isMobile: props.isMobile ?? false,
+ mapLocked: props.mapLocked ?? false,
+ mapPlaces: props.mapPlaces ?? [{ id: 1 }],
+ assignments: { 3: [visit(1, 3, 1)] },
+ markersLocked: false,
+ selectDay,
+ }),
+ { initialProps: initial }
+ );
+}
+
+beforeEach(() => {
+ useAssistantStore.setState({
+ window: { open: true, minimized: false },
+ byTrip: {},
+ lastApplied: null,
+ mapLink: null,
+ camera: null,
+ preview: null,
+ windowRect: null,
+ });
+ useTripStore.setState({
+ trip: { id: TRIP } as Trip,
+ places: [place(1), place(2)],
+ categories: [{ id: 1, name: 'Sights', color: '#3366ff', icon: 'Landmark' } as Category],
+ });
+ useSettingsStore.setState((s) => ({ settings: { ...s.settings, assistant_map_follow: true } }));
+ vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query }) as MediaQueryList);
+});
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+describe('useAssistantMapLink: ghosts', () => {
+ it('FE-PLANNER-AI-MAP-001: a new place is a ghost in its colour, a place on the map pulses, a filtered one is a ghost', () => {
+ setMessages([...held('a', NEW), ...held('b', ON_MAP), ...held('c', FILTERED)]);
+ const { result } = link();
+ expect(result.current.assistantGhosts).toEqual([
+ { id: 'a', lat: 41.9, lng: 12.48, name: 'Pantheon', color: '#3366ff', icon: 'Landmark' },
+ { id: 'c', lat: 41.02, lng: 12, name: 'Place 2', color: '#3366ff', icon: 'Landmark' },
+ ]);
+
+ act(() => useAssistantStore.getState().setMapHover(TRIP, 'b', 'card'));
+ expect(result.current.assistantHoveredId).toBe('b');
+ expect(result.current.assistantPulsePlaceId).toBe(1);
+
+ act(() => result.current.onAssistantGhostHover?.('a'));
+ expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'a', source: 'map' });
+ expect(result.current.assistantPulsePlaceId).toBeNull();
+ });
+
+ it('FE-PLANNER-AI-MAP-002: a found place under the pointer shows as a marked pin of its own', () => {
+ const { result } = link();
+ const pin = { tripId: TRIP, ref: 's:0', lat: 41.5, lng: 12.5, name: 'Da Enzo' };
+ act(() => useAssistantStore.getState().setPreviewPin(pin));
+ expect(result.current.assistantGhosts).toEqual([{ id: 'preview:s:0', lat: 41.5, lng: 12.5, name: 'Da Enzo' }]);
+ expect(result.current.assistantHoveredId).toBe('preview:s:0');
+ });
+});
+
+describe('useAssistantMapLink: moves of its own', () => {
+ it('FE-PLANNER-AI-MAP-003: a block that finishes arriving frames the map once; one waiting at mount does not', () => {
+ setMessages(held('old', NEW));
+ const { rerender } = link();
+ expect(useAssistantStore.getState().camera).toBeNull();
+
+ act(() => setMessages(held('new', NEW), { isRunning: true }));
+ rerender({});
+ expect(useAssistantStore.getState().camera).toBeNull();
+ act(() => setMessages(held('new', NEW), { isRunning: false }));
+ rerender({});
+ expect(useAssistantStore.getState().camera).toMatchObject({ tripId: TRIP, mode: 'fit', why: 'auto', seq: 1 });
+ rerender({});
+ expect(useAssistantStore.getState().camera?.seq).toBe(1);
+ });
+
+ it.each([
+ [
+ 'the setting is off',
+ () => useSettingsStore.setState((s) => ({ settings: { ...s.settings, assistant_map_follow: false } })),
+ {},
+ ],
+ ['the desktop map is locked', () => undefined, { mapLocked: true }],
+ ['motion is reduced', () => vi.stubGlobal('matchMedia', reducedMedia), {}],
+ ['the phone shows the chat, not the map', () => undefined, { isMobile: true }],
+ ])('FE-PLANNER-AI-MAP-004: no move of its own when %s', (_why, arrange, props: Props) => {
+ arrange();
+ const { rerender } = link(vi.fn(), props);
+ act(() => useAssistantStore.getState().requestCamera(TRIP, [[1, 1]], 'pan', 'user'));
+ const userSeq = useAssistantStore.getState().camera?.seq;
+ act(() =>
+ setMessages([], {
+ live: {
+ activity: { toolCallId: 's', label: 'Monti', area: { type: 'point', lat: 41.9, lng: 12.49, radiusM: 500 } },
+ },
+ })
+ );
+ rerender(props);
+ expect(useAssistantStore.getState().camera?.seq).toBe(userSeq);
+ });
+
+ it('FE-PLANNER-AI-MAP-005: the area a step searches is framed, at most every two seconds; the phone follows once the chat is folded', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-10-21T08:00:00Z'));
+ useAssistantStore.setState({ window: { open: true, minimized: true } });
+ const area = (lat: number) => ({ type: 'point' as const, lat, lng: 12.49, radiusM: 500 });
+ const { rerender } = link(vi.fn(), { isMobile: true });
+
+ act(() => setMessages([], { live: { activity: { toolCallId: 's1', label: 'Monti', area: area(41.9) } } }));
+ rerender({ isMobile: true });
+ expect(useAssistantStore.getState().camera).toMatchObject({ why: 'auto', seq: 1 });
+
+ act(() => setMessages([], { live: { activity: { toolCallId: 's2', label: 'Esquilino', area: area(41.89) } } }));
+ rerender({ isMobile: true });
+ expect(useAssistantStore.getState().camera?.seq).toBe(1);
+
+ vi.setSystemTime(new Date('2026-10-21T08:00:03Z'));
+ act(() => setMessages([], { live: { activity: { toolCallId: 's3', label: 'Celio', area: area(41.88) } } }));
+ rerender({ isMobile: true });
+ expect(useAssistantStore.getState().camera?.seq).toBe(2);
+ });
+});
+
+describe('useAssistantMapLink: after applying', () => {
+ it('FE-PLANNER-AI-MAP-006: the new place keeps its ghost until it is in the trip, then pops once and its day is picked', () => {
+ vi.useFakeTimers();
+ const selectDay = vi.fn();
+ const receipt: AssistantMessagePart = {
+ type: 'data-trek-applied',
+ data: { toolCallId: 'a', entity: 'place', ids: [77], dayId: 3, undoUntil: null },
+ };
+ const { result, rerender } = link(selectDay);
+ act(() => {
+ setMessages([...held('a', NEW, { approved: true }), receipt]);
+ useAssistantStore.setState({ lastApplied: { tripId: TRIP, applied: receipt.data } });
+ });
+ rerender({});
+ expect(result.current.assistantGhosts?.map((g) => g.id)).toEqual(['a']);
+ expect(result.current.poppedPlaceIds).toEqual([]);
+ expect(selectDay).not.toHaveBeenCalled();
+
+ act(() => useTripStore.setState((s) => ({ places: [...s.places, place(77)] })));
+ rerender({});
+ expect(result.current.assistantGhosts).toEqual([]);
+ expect(result.current.poppedPlaceIds).toEqual([77]);
+ expect(selectDay).toHaveBeenCalledOnce();
+ expect(selectDay).toHaveBeenCalledWith(3);
+
+ act(() => vi.advanceTimersByTime(1000));
+ expect(result.current.poppedPlaceIds).toEqual([]);
+ rerender({});
+ expect(selectDay).toHaveBeenCalledOnce();
+ });
+
+ function arrive(toolCallId: string, placeId: number, dayId: number) {
+ const receipt: AssistantMessagePart = {
+ type: 'data-trek-applied',
+ data: { toolCallId, entity: 'place', ids: [placeId], dayId, undoUntil: null },
+ };
+ act(() => useAssistantStore.setState({ lastApplied: { tripId: TRIP, applied: receipt.data } }));
+ }
+
+ it('FE-PLANNER-AI-MAP-006b: a block picks one day once it has run, the day of its last change', () => {
+ useTripStore.setState((s) => ({ places: [...s.places, place(77), place(78), place(79)] }));
+ const selectDay = vi.fn();
+ act(() => setMessages([], { isRunning: true }));
+ const { result, rerender } = link(selectDay);
+
+ arrive('a', 77, 3);
+ rerender({});
+ arrive('b', 78, 3);
+ rerender({});
+ arrive('c', 79, 5);
+ rerender({});
+ expect(result.current.poppedPlaceIds).toEqual([79]);
+ expect(selectDay).not.toHaveBeenCalled();
+
+ act(() => setMessages([], { isRunning: false }));
+ rerender({});
+ expect(selectDay).toHaveBeenCalledOnce();
+ expect(selectDay).toHaveBeenCalledWith(5);
+ });
+
+ it('FE-PLANNER-AI-MAP-006c: with "map follows the assistant" off the place pops but no day is picked', () => {
+ useSettingsStore.setState((s) => ({ settings: { ...s.settings, assistant_map_follow: false } }));
+ useTripStore.setState((s) => ({ places: [...s.places, place(77)] }));
+ const selectDay = vi.fn();
+ const { result, rerender } = link(selectDay);
+
+ arrive('a', 77, 3);
+ rerender({});
+ expect(result.current.poppedPlaceIds).toEqual([77]);
+ expect(selectDay).not.toHaveBeenCalled();
+ });
+});
+
+describe('useAssistantCompareView', () => {
+ it('FE-PLANNER-AI-MAP-007: "before" shows the snapshot days without the places the block made, and locks the pins', () => {
+ const view: AssignmentsMap = { 3: [visit(1, 3), visit(9, 3)], 5: [visit(2, 5)] };
+ const places = [place(1), place(77)];
+ const render = () =>
+ renderHook(() => useAssistantCompareView({ tripId: TRIP, assignments: view, storedAssignments: view, places }));
+
+ const after = render();
+ expect(after.result.current).toEqual({ assignments: view, places, markersLocked: false });
+
+ setMessages([], {
+ compare: {
+ blockKey: 'a',
+ mode: 'before',
+ snapshot: { 3: [visit(1, 3)] },
+ newPlaceCalls: ['a'],
+ addedPlaceIds: [77],
+ },
+ });
+ const before = render();
+ expect(before.result.current.markersLocked).toBe(true);
+ expect(before.result.current.assignments[3]?.map((v) => v.id)).toEqual([1]);
+ expect(before.result.current.assignments[5]).toBe(view[5]);
+ expect(before.result.current.places.map((p) => p.id)).toEqual([1]);
+ });
+});
diff --git a/client/src/pages/tripPlanner/useAssistantMapLink.ts b/client/src/pages/tripPlanner/useAssistantMapLink.ts
new file mode 100644
index 000000000..3788cfc1a
--- /dev/null
+++ b/client/src/pages/tripPlanner/useAssistantMapLink.ts
@@ -0,0 +1,316 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import type { AssistantGhost, AssistantMapProps } from '../../components/Map/mapViewContract';
+import {
+ activityFocus,
+ blockDays,
+ changeEffects,
+ dayPoints,
+ ghostCandidates,
+ liveGhosts,
+ overlaySnapshot,
+ windowCover,
+ type GhostCandidate,
+ type LatLng,
+} from '../../store/assistantMapModel';
+import { chatOf, useAssistantStore } from '../../store/assistantStore';
+import { useSettingsStore } from '../../store/settingsStore';
+import { useTripStore } from '../../store/tripStore';
+import type { AssignmentsMap, Category, Place } from '../../types';
+import { prefersReducedMotion } from '../../utils/reducedMotion';
+
+/** Moves the map was not asked for come at most this often while the assistant works. */
+const AUTO_CAMERA_GAP_MS = 2000;
+/** How long a place that just arrived from an applied suggestion pops. */
+const POP_MS = 1000;
+const NO_IDS: readonly number[] = [];
+
+interface CompareViewOptions {
+ tripId: number;
+ /** The planner's view of the plan (hotel and hidden service stops left out). */
+ assignments: AssignmentsMap;
+ /** The plan as the store holds it. */
+ storedAssignments: AssignmentsMap;
+ places: P[];
+}
+
+/**
+ * "Before" on the map: while the user compares, the days the last applied
+ * block touched show their plan from just before it ran, the places it made
+ * are left off, and no pin can be dragged onto a day, since the plan shown is
+ * not the one a drop would change. Otherwise everything passes through as is.
+ */
+export function useAssistantCompareView
({
+ tripId,
+ assignments,
+ storedAssignments,
+ places,
+}: CompareViewOptions
): { assignments: AssignmentsMap; places: P[]; markersLocked: boolean } {
+ const compare = useAssistantStore((s) => chatOf(s, tripId).compare);
+ return useMemo(() => {
+ if (compare?.mode !== 'before') return { assignments, places, markersLocked: false };
+ const added = new Set(compare.addedPlaceIds);
+ return {
+ assignments: overlaySnapshot(assignments, storedAssignments, compare.snapshot),
+ places: added.size > 0 ? places.filter((p) => !added.has(p.id)) : places,
+ markersLocked: true,
+ };
+ }, [assignments, compare, places, storedAssignments]);
+}
+
+interface MapLinkOptions {
+ tripId: number;
+ isMobile: boolean;
+ /** The desktop map lock: a locked map never moves on its own. */
+ mapLocked: boolean;
+ /** The places on the plan map now, so a suggestion about one of them marks its pin instead of a ghost. */
+ mapPlaces: readonly { id: number }[];
+ assignments: AssignmentsMap;
+ markersLocked: boolean;
+ selectDay: (dayId: number) => void;
+}
+
+/**
+ * Everything the assistant shows on the trip map, as props for MapViewAuto:
+ * its waiting suggestions as ghosts tied to their rows in the chat, the
+ * camera moves it asks for, and the pop of a place that arrives from an
+ * applied suggestion. Desktop and phone both get it through useTripPlanner.
+ */
+export function useAssistantMapLink(options: MapLinkOptions): AssistantMapProps {
+ const { tripId, isMobile, mapLocked, mapPlaces, assignments, markersLocked, selectDay } = options;
+ const places = useTripStore((s) => s.places);
+ const categories = useTripStore((s) => s.categories);
+ const candidates = useGhostCandidates(tripId);
+ const knownIds = useMemo(() => new Set(places.map((p) => p.id)), [places]);
+ const live = useMemo(() => liveGhosts(candidates, knownIds), [candidates, knownIds]);
+ const preview = useAssistantStore((s) => (s.preview?.tripId === tripId ? s.preview : null));
+ const linkId = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null));
+ const setMapHover = useAssistantStore((s) => s.setMapHover);
+
+ const { ghosts, existing } = useMemo(
+ () => ghostPins(live, { places, categories, onMap: new Set(mapPlaces.map((p) => p.id)) }),
+ [categories, live, mapPlaces, places]
+ );
+ const previewId = preview ? `preview:${preview.ref}` : null;
+ const shownGhosts = useMemo(() => {
+ if (!preview || !previewId) return ghosts;
+ return [...ghosts, { id: previewId, lat: preview.lat, lng: preview.lng, name: preview.name }];
+ }, [ghosts, preview, previewId]);
+
+ const onGhostHover = useCallback((id: string | null) => setMapHover(tripId, id, 'map'), [setMapHover, tripId]);
+ const onGhostClick = useCallback((id: string) => setMapHover(tripId, id, 'map'), [setMapHover, tripId]);
+
+ const autoAllowed = useAutoCameraAllowed(isMobile, mapLocked);
+ useAutoCamera({ tripId, live, ghosts, assignments, autoAllowed });
+ const assistantCamera = useCameraProp(tripId, isMobile);
+ const poppedPlaceIds = useArrivalPop(tripId, knownIds, selectDay, autoAllowed);
+
+ return useMemo(
+ () => ({
+ assistantGhosts: shownGhosts,
+ assistantHoveredId: previewId ?? linkId,
+ onAssistantGhostHover: onGhostHover,
+ onAssistantGhostClick: onGhostClick,
+ assistantPulsePlaceId: linkId ? (existing.get(linkId) ?? null) : null,
+ assistantCamera,
+ poppedPlaceIds,
+ markersLocked,
+ }),
+ [
+ assistantCamera,
+ existing,
+ linkId,
+ markersLocked,
+ onGhostClick,
+ onGhostHover,
+ poppedPlaceIds,
+ previewId,
+ shownGhosts,
+ ]
+ );
+}
+
+/**
+ * The thread's ghost candidates, re-read only when they change. The selector
+ * hands back a string so a streamed text delta, which rewrites the messages
+ * on every frame, does not render the whole planner each time.
+ */
+function useGhostCandidates(tripId: number): GhostCandidate[] {
+ const signature = useAssistantStore((s) => JSON.stringify(ghostCandidates(chatOf(s, tripId).messages)));
+ return useMemo(() => JSON.parse(signature) as GhostCandidate[], [signature]);
+}
+
+interface GhostPinSources {
+ places: readonly Place[];
+ categories: readonly Category[];
+ /** The places with a pin of their own on the map now. */
+ onMap: ReadonlySet;
+}
+
+/**
+ * The ghosts to draw, and for a suggestion about a place that has its own pin
+ * on the map, that place (its pin pulses instead). A place that is in the
+ * trip but not on the map (filtered out by category) is drawn as a ghost too,
+ * or hovering its row would show nothing.
+ */
+function ghostPins(
+ live: readonly GhostCandidate[],
+ { places, categories, onMap }: GhostPinSources
+): { ghosts: AssistantGhost[]; existing: Map } {
+ const ghosts: AssistantGhost[] = [];
+ const existing = new Map();
+ const categoryOf = (id: number | null | undefined) => categories.find((c) => c.id === id);
+ for (const { toolCallId, effect } of live) {
+ if (effect.place) {
+ const category = categoryOf(effect.place.categoryId);
+ const { lat, lng, name } = effect.place;
+ ghosts.push({ id: toolCallId, lat, lng, name, color: category?.color, icon: category?.icon });
+ continue;
+ }
+ const placeId = effect.placeId;
+ if (placeId === null) continue;
+ if (onMap.has(placeId)) {
+ existing.set(toolCallId, placeId);
+ continue;
+ }
+ const place = places.find((p) => p.id === placeId);
+ if (place?.lat == null || place.lng == null) continue;
+ const category = categoryOf(place.category_id);
+ ghosts.push({
+ id: toolCallId,
+ lat: place.lat,
+ lng: place.lng,
+ name: place.name,
+ color: category?.color,
+ icon: category?.icon,
+ });
+ }
+ return { ghosts, existing };
+}
+
+/**
+ * Whether the map may move without being asked: the setting is on, motion is
+ * not reduced, the desktop map is not locked, and on the phone the chat is
+ * folded down to its pill so the map is what the user sees.
+ */
+function useAutoCameraAllowed(isMobile: boolean, mapLocked: boolean): boolean {
+ const follow = useSettingsStore((s) => s.settings.assistant_map_follow) !== false;
+ const minimized = useAssistantStore((s) => s.window.open && s.window.minimized);
+ if (!follow || prefersReducedMotion()) return false;
+ return isMobile ? minimized : !mapLocked;
+}
+
+interface AutoCameraOptions {
+ tripId: number;
+ live: readonly GhostCandidate[];
+ ghosts: readonly AssistantGhost[];
+ assignments: AssignmentsMap;
+ autoAllowed: boolean;
+}
+
+/**
+ * The two moves the map makes on its own: to a new block of suggestions once
+ * the answer holding it is complete (once per block, framing the ghosts with
+ * the days they join), and to the area a running step searches (at most every
+ * two seconds, and never back afterwards). A block already waiting when the
+ * planner opens is left where it is.
+ */
+function useAutoCamera({ tripId, live, ghosts, assignments, autoAllowed }: AutoCameraOptions): void {
+ const requestCamera = useAssistantStore((s) => s.requestCamera);
+ const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
+ const activity = useAssistantStore((s) => chatOf(s, tripId).live.activity);
+ const waiting = live.filter((c) => c.status === 'waiting');
+ const blockKey = isRunning ? null : (waiting[0]?.toolCallId ?? null);
+ const seenBlock = useRef(undefined);
+ const lastAuto = useRef(0);
+
+ useEffect(() => {
+ const first = seenBlock.current === undefined;
+ if (!first && (blockKey === null || blockKey === seenBlock.current)) return;
+ seenBlock.current = blockKey;
+ if (first || blockKey === null || !autoAllowed) return;
+ const points: LatLng[] = [
+ ...ghosts.map((g): LatLng => [g.lat, g.lng]),
+ ...dayPoints(assignments, blockDays(waiting.map((c) => c.effect))),
+ ];
+ requestCamera(tripId, points, 'fit', 'auto');
+ }, [assignments, autoAllowed, blockKey, ghosts, requestCamera, tripId, waiting]);
+
+ useEffect(() => {
+ if (!activity?.area || !autoAllowed) return;
+ const now = Date.now();
+ if (now - lastAuto.current < AUTO_CAMERA_GAP_MS) return;
+ lastAuto.current = now;
+ requestCamera(tripId, activityFocus(activity.area), 'fit', 'auto');
+ }, [activity, autoAllowed, requestCamera, tripId]);
+}
+
+/** The store's camera request for this trip as the map's prop, with what the floating window covers. */
+function useCameraProp(tripId: number, isMobile: boolean): AssistantMapProps['assistantCamera'] {
+ const camera = useAssistantStore((s) => (s.camera?.tripId === tripId ? s.camera : null));
+ const windowRect = useAssistantStore((s) => s.windowRect);
+ return useMemo(() => {
+ if (!camera) return null;
+ const cover =
+ isMobile || typeof window === 'undefined'
+ ? null
+ : windowCover(windowRect, window.innerWidth, document.documentElement.dir === 'rtl');
+ return {
+ key: String(camera.seq),
+ points: camera.points,
+ mode: camera.mode,
+ animate: !prefersReducedMotion(),
+ auto: camera.why === 'auto',
+ cover,
+ };
+ }, [camera, isMobile, windowRect]);
+}
+
+/**
+ * When a place from an applied suggestion reaches the trip, its pin pops for
+ * a moment. Once the block has run, the day of its last arrived change is
+ * selected, which frames the map on it: one move per block, and only where
+ * the map may move on its own. The change applied before the planner opened
+ * is left alone.
+ */
+function useArrivalPop(
+ tripId: number,
+ knownIds: ReadonlySet,
+ selectDay: (dayId: number) => void,
+ autoAllowed: boolean
+) {
+ const applied = useAssistantStore((s) => (s.lastApplied?.tripId === tripId ? s.lastApplied.applied : null));
+ const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
+ const effect = useAssistantStore((s) =>
+ applied ? (changeEffects(chatOf(s, tripId).messages).get(applied.toolCallId) ?? null) : null
+ );
+ const key = applied?.toolCallId ?? null;
+ const placeId = applied?.entity === 'place' ? (applied.ids[0] ?? null) : (effect?.placeId ?? null);
+ const arrived = key !== null && effect?.kind !== 'remove' && placeId !== null && knownIds.has(placeId);
+ const [popDone, setPopDone] = useState(key);
+ const handled = useRef(key);
+ const pendingDay = useRef(null);
+ const popping = arrived && key !== popDone;
+
+ useEffect(() => {
+ if (!popping) return;
+ const timer = window.setTimeout(() => setPopDone(key), POP_MS);
+ return () => window.clearTimeout(timer);
+ }, [key, popping]);
+
+ useEffect(() => {
+ if (!arrived || handled.current === key) return;
+ handled.current = key;
+ const dayId = applied?.dayId ?? effect?.dayId ?? null;
+ if (dayId !== null) pendingDay.current = dayId;
+ }, [applied, arrived, effect, key]);
+
+ useEffect(() => {
+ const dayId = pendingDay.current;
+ if (isRunning || dayId === null) return;
+ pendingDay.current = null;
+ if (autoAllowed) selectDay(dayId);
+ }, [arrived, autoAllowed, isRunning, key, selectDay]);
+
+ return useMemo(() => (popping && placeId !== null ? [placeId] : NO_IDS), [placeId, popping]);
+}
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index 8eca48cf7..0ebf7669f 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -49,6 +49,8 @@ import { useDayDelete } from './useDayDelete'
import { useDayAdd } from './useDayAdd'
import { usePlannerDialogs } from './usePlannerDialogs'
import { usePlaceDeepLink } from './usePlaceDeepLink'
+import { useAssistantDeepLink } from './useAssistantDeepLink'
+import { useAssistantCompareView, useAssistantMapLink } from './useAssistantMapLink'
import { usePlannerMapView } from './usePlannerMapView'
import { useMapPlaces } from './useMapPlaces'
import { useRoadtripFeed } from './useRoadtripFeed'
@@ -432,8 +434,10 @@ export function useTripPlanner() {
useTripWebSocket(tripId, invalidateTourPlaceIds)
+ // While the assistant compares a block it changed with the plan before it, the map shows that plan.
+ const assistantView = useAssistantCompareView({ tripId, assignments, storedAssignments, places })
const { expandedDayIds, setExpandedDayIds, mapPlaces, dayOrderMap, dayPlaces } = useMapPlaces({
- places, assignments, selectedDayId, days, tripAccommodations, reservations, toursEnabled, tripId, tourPlaceIds, isMobile,
+ places: assistantView.places, assignments: assistantView.assignments, selectedDayId, days, tripAccommodations, reservations, toursEnabled, tripId, tourPlaceIds, isMobile,
})
// Fed whenever the addon is on, not only while the mode is being looked at.
@@ -448,7 +452,7 @@ export function useTripPlanner() {
const roadtripVias = useRoadtripVias(tripId, !!enabledAddons.roadtrip)
// MDT-02: where the stages of the multi-day tours run, so a day holding one routes to and draws that stage.
const tourStageStops = useMemo(() => tourStageStopsByPlace(tours), [tours])
- const { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments, roadtripVias: roadtripVias.byDay, tourStageStops }, selectedDayId, routeShown, routeProfile, tripAccommodations)
+ const { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments: assistantView.assignments, roadtripVias: roadtripVias.byDay, tourStageStops }, selectedDayId, routeShown, routeProfile, tripAccommodations)
// Road trip mode already draws the whole trip its own way, so the overview stands
// down there rather than drawing a second set of lines over it.
const overviewActive = overviewShown && !roadtripMode
@@ -474,6 +478,10 @@ export function useTripPlanner() {
setMobileSidebarOpen(null)
updateRouteForDay(dayId)
}, [updateRouteForDay, isMobileRef, mapLockedRef, setFitKey])
+ useAssistantDeepLink({ searchParams, setSearchParams, tripId, days, selectDay: handleSelectDay })
+ const assistantMap = useAssistantMapLink({
+ tripId, isMobile, mapLocked, mapPlaces, assignments: assistantView.assignments, markersLocked: assistantView.markersLocked, selectDay: handleSelectDay,
+ })
const handlePlaceClick = useCallback((placeId: number | null, assignmentId?: number | null) => {
if (assignmentId) {
@@ -749,5 +757,7 @@ export function useTripPlanner() {
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
selectedPlace, dayOrderMap, dayPlaces,
mapTileUrl, fontStyle, splashDone,
+ // The assistant on the map, handed to MapViewAuto whole by both shells.
+ assistantMap,
}
}
diff --git a/client/src/repo/aiConnectionsRepo.test.ts b/client/src/repo/aiConnectionsRepo.test.ts
new file mode 100644
index 000000000..7a464a7db
--- /dev/null
+++ b/client/src/repo/aiConnectionsRepo.test.ts
@@ -0,0 +1,125 @@
+/**
+ * FE-AI-CONN-REPO-001 to FE-AI-CONN-REPO-006
+ *
+ * Connections are online-only and parsed, never cast: a credential lives on
+ * the server, and the client only ever handles the view of it.
+ */
+import type { AiAdminConnectionResponse, AiConnectionView, AiStatusResponse } from '@trek/shared';
+import { http, HttpResponse } from 'msw';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { server } from '../../tests/helpers/msw/server';
+import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
+import { aiConnectionsRepo } from './aiConnectionsRepo';
+import { OnlineOnlyError } from './onlineOnly';
+
+const VIEW: AiConnectionView = {
+ scope: 'user',
+ kind: 'anthropic',
+ model: 'claude-sonnet',
+ hint: '…a1b2',
+ status: 'ok',
+ host: 'api.anthropic.com',
+};
+
+const STATUS: AiStatusResponse = {
+ available: true,
+ policy: 'instance_or_user',
+ canConnectOwn: true,
+ effective: { source: 'user', kind: 'anthropic', model: 'claude-sonnet', host: 'api.anthropic.com', paidBy: 'user' },
+ own: VIEW,
+ instance: null,
+ quota: null,
+ privacyAcked: true,
+ retention: { mode: 'days', days: 90 },
+ openrouterOAuth: true,
+};
+
+const START = {
+ flowId: '7d9f2c1e-3b4a-4c5d-8e6f-0a1b2c3d4e5f',
+ authorizeUrl: 'https://openrouter.ai/auth?x=1',
+ expiresAt: '2026-10-21T08:10:00.000Z',
+};
+
+beforeEach(() => _resetNetworkMode());
+afterEach(() => _resetNetworkMode());
+
+describe('aiConnectionsRepo', () => {
+ it('FE-AI-CONN-REPO-001: reads the status', async () => {
+ server.use(http.get('/api/ai/status', () => HttpResponse.json(STATUS)));
+ await expect(aiConnectionsRepo.status()).resolves.toEqual(STATUS);
+ });
+
+ it('FE-AI-CONN-REPO-002: connects a key and answers with the view, never the key', async () => {
+ let sent: unknown = null;
+ server.use(
+ http.put('/api/ai/connection', async ({ request }) => {
+ sent = await request.json();
+ return HttpResponse.json(VIEW);
+ })
+ );
+ await expect(aiConnectionsRepo.connectKey({ kind: 'anthropic', apiKey: 'sk-ant-12345678' })).resolves.toEqual(VIEW);
+ expect(sent).toEqual({ kind: 'anthropic', apiKey: 'sk-ant-12345678' });
+ });
+
+ it('FE-AI-CONN-REPO-003: OpenRouter starts on the user route or, for the instance, the admin route', async () => {
+ const seen: string[] = [];
+ server.use(
+ http.post('/api/ai/connect/openrouter/start', () => {
+ seen.push('user');
+ return HttpResponse.json(START);
+ }),
+ http.post('/api/admin/ai/connect/openrouter/start', () => {
+ seen.push('instance');
+ return HttpResponse.json(START);
+ })
+ );
+ await expect(aiConnectionsRepo.openrouterStart('user')).resolves.toEqual(START);
+ await expect(aiConnectionsRepo.openrouterStart('instance')).resolves.toEqual(START);
+ expect(seen).toEqual(['user', 'instance']);
+ });
+
+ it('FE-AI-CONN-REPO-004: reads and writes the admin settings and the instance connection', async () => {
+ const settings = { policy: 'user' as const, dailyTurns: 50, retention: { mode: 'forever' as const } };
+ const connection: AiAdminConnectionResponse = {
+ connection: {
+ ...VIEW,
+ scope: 'instance',
+ kind: 'custom',
+ host: 'ollama.lan',
+ baseUrl: 'http://ollama.lan:11434/v1',
+ },
+ prefill: null,
+ };
+ server.use(
+ http.get('/api/admin/ai/settings', () => HttpResponse.json(settings)),
+ http.put('/api/admin/ai/settings', async ({ request }) => HttpResponse.json(await request.json())),
+ http.get('/api/admin/ai/connection', () => HttpResponse.json(connection)),
+ http.get('/api/admin/ai/models', () =>
+ HttpResponse.json({ models: [{ id: 'qwen3', label: 'Qwen 3', supportsTools: true }] })
+ )
+ );
+ await expect(aiConnectionsRepo.adminSettings()).resolves.toEqual(settings);
+ await expect(aiConnectionsRepo.saveAdminSettings({ ...settings, dailyTurns: 20 })).resolves.toEqual({
+ ...settings,
+ dailyTurns: 20,
+ });
+ await expect(aiConnectionsRepo.adminConnection()).resolves.toEqual(connection);
+ await expect(aiConnectionsRepo.adminModels()).resolves.toEqual([
+ { id: 'qwen3', label: 'Qwen 3', supportsTools: true },
+ ]);
+ });
+
+ it('FE-AI-CONN-REPO-005: an answer outside the contract fails here', async () => {
+ server.use(http.get('/api/ai/status', () => HttpResponse.json({ available: 'yes' })));
+ await expect(aiConnectionsRepo.status()).rejects.toThrow();
+ });
+
+ it('FE-AI-CONN-REPO-006: while offline every call refuses before a request', async () => {
+ setForcedOffline(true);
+ await expect(aiConnectionsRepo.status()).rejects.toBeInstanceOf(OnlineOnlyError);
+ await expect(aiConnectionsRepo.disconnect()).rejects.toBeInstanceOf(OnlineOnlyError);
+ await expect(aiConnectionsRepo.chatgptStart()).rejects.toBeInstanceOf(OnlineOnlyError);
+ await expect(aiConnectionsRepo.adminSettings()).rejects.toBeInstanceOf(OnlineOnlyError);
+ await expect(aiConnectionsRepo.claudeConnector()).rejects.toBeInstanceOf(OnlineOnlyError);
+ });
+});
diff --git a/client/src/repo/aiConnectionsRepo.ts b/client/src/repo/aiConnectionsRepo.ts
new file mode 100644
index 000000000..62fa65f2f
--- /dev/null
+++ b/client/src/repo/aiConnectionsRepo.ts
@@ -0,0 +1,124 @@
+import {
+ aiAdminConnectionResponseSchema,
+ aiAdminSettingsSchema,
+ aiAuthStartResponseSchema,
+ aiClaudeConnectorResponseSchema,
+ aiConnectionViewSchema,
+ aiModelListResponseSchema,
+ aiStatusResponseSchema,
+ type AiAdminSettings,
+ type AiApiKeyConnectRequest,
+ type AiChatgptFinishRequest,
+ type AiConnectionScope,
+ type AiInstanceConnectionRequest,
+ type AiOpenrouterFinishRequest,
+ type AiPrivacyAckRequest,
+} from '@trek/shared';
+import { aiAdminApi, aiApi, claudeConnectorApi } from '../api/assistant';
+import { requireOnline } from './onlineOnly';
+
+/**
+ * AI connections: the user's own, the one the instance provides, and the
+ * link that adds TREK to Claude.
+ *
+ * **Deliberately online-only, and no Dexie table.** A connection is a sealed
+ * credential on the server; the client only ever sees a view of it, and every
+ * action here needs the server (and usually the provider) to answer. Answers
+ * are parsed against the shared schema rather than cast, so a server that
+ * answered something else fails here, naming the field.
+ */
+export const aiConnectionsRepo = {
+ async status() {
+ requireOnline();
+ return aiStatusResponseSchema.parse(await aiApi.status());
+ },
+
+ async models() {
+ requireOnline();
+ return aiModelListResponseSchema.parse(await aiApi.models()).models;
+ },
+
+ async connectKey(body: AiApiKeyConnectRequest) {
+ requireOnline();
+ return aiConnectionViewSchema.parse(await aiApi.connectKey(body));
+ },
+
+ async setModel(model: string) {
+ requireOnline();
+ return aiConnectionViewSchema.parse(await aiApi.setModel(model));
+ },
+
+ async disconnect(): Promise {
+ requireOnline();
+ await aiApi.disconnect();
+ },
+
+ async ackPrivacy(body: AiPrivacyAckRequest): Promise {
+ requireOnline();
+ await aiApi.ackPrivacy(body);
+ },
+
+ async chatgptStart() {
+ requireOnline();
+ return aiAuthStartResponseSchema.parse(await aiApi.chatgptStart());
+ },
+
+ async chatgptFinish(body: AiChatgptFinishRequest) {
+ requireOnline();
+ return aiConnectionViewSchema.parse(await aiApi.chatgptFinish(body));
+ },
+
+ async chatgptSignOut(): Promise {
+ requireOnline();
+ await aiApi.chatgptSignOut();
+ },
+
+ async openrouterStart(scope: AiConnectionScope) {
+ requireOnline();
+ return aiAuthStartResponseSchema.parse(await aiApi.openrouterStart(scope));
+ },
+
+ async openrouterFinish(body: AiOpenrouterFinishRequest) {
+ requireOnline();
+ return aiConnectionViewSchema.parse(await aiApi.openrouterFinish(body));
+ },
+
+ // ── Admin ──────────────────────────────────────────────────────────────────
+
+ async adminSettings() {
+ requireOnline();
+ return aiAdminSettingsSchema.parse(await aiAdminApi.settings());
+ },
+
+ async saveAdminSettings(body: AiAdminSettings) {
+ requireOnline();
+ return aiAdminSettingsSchema.parse(await aiAdminApi.saveSettings(body));
+ },
+
+ async adminConnection() {
+ requireOnline();
+ return aiAdminConnectionResponseSchema.parse(await aiAdminApi.connection());
+ },
+
+ async saveAdminConnection(body: AiInstanceConnectionRequest) {
+ requireOnline();
+ return aiAdminConnectionResponseSchema.parse(await aiAdminApi.saveConnection(body));
+ },
+
+ async removeAdminConnection(): Promise {
+ requireOnline();
+ await aiAdminApi.removeConnection();
+ },
+
+ async adminModels() {
+ requireOnline();
+ return aiModelListResponseSchema.parse(await aiAdminApi.models()).models;
+ },
+
+ // ── Claude connector ───────────────────────────────────────────────────────
+
+ async claudeConnector() {
+ requireOnline();
+ return aiClaudeConnectorResponseSchema.parse(await claudeConnectorApi.get());
+ },
+};
diff --git a/client/src/repo/assistantRepo.foundPlaces.test.ts b/client/src/repo/assistantRepo.foundPlaces.test.ts
new file mode 100644
index 000000000..1b818ab13
--- /dev/null
+++ b/client/src/repo/assistantRepo.foundPlaces.test.ts
@@ -0,0 +1,54 @@
+/**
+ * FE-AI-REPO-FOUND-001 to 002
+ *
+ * "+ Day N" on a found place: the repo posts the place's handle and the day,
+ * parses the message the server answers with, and like every assistant call
+ * sends nothing while offline.
+ */
+import type { AssistantAddFoundPlaceResponse } from '@trek/shared';
+import { http, HttpResponse } from 'msw';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { server } from '../../tests/helpers/msw/server';
+import { assistantApi } from '../api/assistant';
+import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
+import { assistantRepo } from './assistantRepo';
+import { OnlineOnlyError } from './onlineOnly';
+
+const BODY = { locale: 'en', timeZone: 'UTC', ref: 'call-1:0', dayId: 31 };
+
+const ANSWER: AssistantAddFoundPlaceResponse = {
+ message: {
+ id: 'msg-9',
+ role: 'assistant',
+ parts: [{ type: 'tool-call', toolCallId: 'c9', toolName: 'add_found_place', args: {}, approval: { id: 'c9' } }],
+ status: 'awaiting_approval',
+ createdAt: '2026-10-21T08:00:00.000Z',
+ },
+};
+
+beforeEach(() => _resetNetworkMode());
+afterEach(() => {
+ _resetNetworkMode();
+ vi.restoreAllMocks();
+});
+
+describe('assistantRepo.addFoundPlace', () => {
+ it('FE-AI-REPO-FOUND-001: posts the handle and the day, and parses the message it gets back', async () => {
+ let sent: unknown = null;
+ server.use(
+ http.post('/api/trips/4/assistant/threads/7/found-places', async ({ request }) => {
+ sent = await request.json();
+ return HttpResponse.json(ANSWER);
+ })
+ );
+ await expect(assistantRepo.addFoundPlace(4, 7, BODY)).resolves.toEqual(ANSWER);
+ expect(sent).toEqual(BODY);
+ });
+
+ it('FE-AI-REPO-FOUND-002: refuses before a request while offline', async () => {
+ setForcedOffline(true);
+ const call = vi.spyOn(assistantApi, 'addFoundPlace');
+ await expect(assistantRepo.addFoundPlace(4, 7, BODY)).rejects.toBeInstanceOf(OnlineOnlyError);
+ expect(call).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/repo/assistantRepo.share.test.ts b/client/src/repo/assistantRepo.share.test.ts
new file mode 100644
index 000000000..7ad591738
--- /dev/null
+++ b/client/src/repo/assistantRepo.share.test.ts
@@ -0,0 +1,63 @@
+/**
+ * FE-AI-REPO-SHARE-001 to 002
+ *
+ * Sharing an answer in the trip chat: the repo posts the previewed text to
+ * the answer's share route, parses the chat message it gets back, and sends
+ * nothing while offline.
+ */
+import type { CollabMessageResponse } from '@trek/shared';
+import { http, HttpResponse } from 'msw';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { server } from '../../tests/helpers/msw/server';
+import { assistantApi } from '../api/assistant';
+import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
+import { assistantRepo } from './assistantRepo';
+import { OnlineOnlyError } from './onlineOnly';
+
+const ANSWER: CollabMessageResponse = {
+ message: {
+ id: 51,
+ trip_id: 4,
+ user_id: 2,
+ text: 'Take the early train.',
+ reply_to: null,
+ created_at: '2026-10-21T08:00:00.000Z',
+ deleted: null,
+ username: 'maria',
+ avatar: null,
+ reply_text: null,
+ reply_username: null,
+ user_avatar: null,
+ avatar_url: null,
+ reactions: [],
+ attachments: [],
+ origin: 'assistant',
+ },
+};
+
+beforeEach(() => _resetNetworkMode());
+afterEach(() => {
+ _resetNetworkMode();
+ vi.restoreAllMocks();
+});
+
+describe('assistantRepo.share', () => {
+ it('FE-AI-REPO-SHARE-001: posts the text to the answer and parses the chat message it gets back', async () => {
+ let sent: unknown = null;
+ server.use(
+ http.post('/api/trips/4/assistant/threads/7/messages/msg-12/share', async ({ request }) => {
+ sent = await request.json();
+ return HttpResponse.json(ANSWER, { status: 201 });
+ })
+ );
+ await expect(assistantRepo.share(4, 7, 'msg-12', { text: 'Take the early train.' })).resolves.toEqual(ANSWER);
+ expect(sent).toEqual({ text: 'Take the early train.' });
+ });
+
+ it('FE-AI-REPO-SHARE-002: refuses before a request while offline', async () => {
+ setForcedOffline(true);
+ const call = vi.spyOn(assistantApi, 'share');
+ await expect(assistantRepo.share(4, 7, 'msg-12', { text: 'Hi' })).rejects.toBeInstanceOf(OnlineOnlyError);
+ expect(call).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/repo/assistantRepo.test.ts b/client/src/repo/assistantRepo.test.ts
new file mode 100644
index 000000000..43544260c
--- /dev/null
+++ b/client/src/repo/assistantRepo.test.ts
@@ -0,0 +1,108 @@
+/**
+ * FE-AI-REPO-001 to FE-AI-REPO-005
+ *
+ * The assistant is online-only on purpose (there is no model on the device
+ * and no cached chats), so the repo makes two promises: nothing is sent while
+ * offline, the manual offline switch included, and answers are parsed against
+ * the shared contract instead of cast.
+ */
+import type { AssistantThreadResponse } from '@trek/shared';
+import { http, HttpResponse } from 'msw';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { server } from '../../tests/helpers/msw/server';
+import { assistantApi } from '../api/assistant';
+import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
+import { assistantRepo } from './assistantRepo';
+import { OnlineOnlyError } from './onlineOnly';
+
+const BASE = '/api/trips/4/assistant';
+
+const THREAD: AssistantThreadResponse = {
+ thread: { id: 7, title: 'Kyoto', lastMessageAt: '2026-10-21T08:00:00.000Z' },
+ messages: [
+ {
+ id: '12',
+ role: 'user',
+ parts: [{ type: 'text', text: 'Plan Friday' }],
+ status: 'complete',
+ createdAt: '2026-10-21T08:00:00.000Z',
+ },
+ ],
+};
+
+beforeEach(() => _resetNetworkMode());
+afterEach(() => {
+ _resetNetworkMode();
+ vi.restoreAllMocks();
+});
+
+describe('assistantRepo', () => {
+ it('FE-AI-REPO-001: reads the thread list and a thread', async () => {
+ server.use(
+ http.get(`${BASE}/threads`, () => HttpResponse.json({ threads: [THREAD.thread] })),
+ http.get(`${BASE}/threads/7`, () => HttpResponse.json(THREAD))
+ );
+ await expect(assistantRepo.threads(4)).resolves.toEqual([THREAD.thread]);
+ await expect(assistantRepo.thread(4, 7)).resolves.toEqual(THREAD);
+ });
+
+ it('FE-AI-REPO-002: an answer outside the contract fails here', async () => {
+ server.use(http.get(`${BASE}/threads`, () => HttpResponse.json({ threads: [{ id: 'seven' }] })));
+ await expect(assistantRepo.threads(4)).rejects.toThrow();
+ });
+
+ it('FE-AI-REPO-003: deletes, undoes and stops through the API', async () => {
+ const hits: string[] = [];
+ server.use(
+ http.delete(`${BASE}/threads/7`, () => {
+ hits.push('delete');
+ return HttpResponse.json({ success: true });
+ }),
+ http.post(`${BASE}/tool-calls/c1/undo`, () => {
+ hits.push('undo');
+ return HttpResponse.json({ undone: true });
+ }),
+ http.post(`${BASE}/stop`, () => {
+ hits.push('stop');
+ return HttpResponse.json({ success: true });
+ })
+ );
+ await assistantRepo.deleteThread(4, 7);
+ await assistantRepo.undo(4, 'c1');
+ await assistantRepo.stop(4);
+ expect(hits).toEqual(['delete', 'undo', 'stop']);
+ });
+
+ it('FE-AI-REPO-004: send and decide hand back the stream the API opened', async () => {
+ const stream = (async function* () {})();
+ const chat = vi.spyOn(assistantApi, 'chat').mockResolvedValue(stream);
+ const decide = vi.spyOn(assistantApi, 'decide').mockResolvedValue(stream);
+ const signal = new AbortController().signal;
+ const ctx = { locale: 'en', timeZone: 'UTC' };
+
+ await expect(assistantRepo.send(4, { ...ctx, threadId: null, text: 'Hi' }, signal)).resolves.toBe(stream);
+ await expect(
+ assistantRepo.decide(4, 7, { ...ctx, decisions: [{ toolCallId: 'c1', approved: false }] }, signal)
+ ).resolves.toBe(stream);
+ expect(chat).toHaveBeenCalledWith(4, { ...ctx, threadId: null, text: 'Hi' }, signal);
+ expect(decide).toHaveBeenCalledWith(4, 7, { ...ctx, decisions: [{ toolCallId: 'c1', approved: false }] }, signal);
+ });
+
+ it('FE-AI-REPO-005: while offline (the manual switch too) every call refuses before a request', async () => {
+ setForcedOffline(true);
+ const chat = vi.spyOn(assistantApi, 'chat');
+ const threads = vi.spyOn(assistantApi, 'threads');
+ const signal = new AbortController().signal;
+
+ await expect(assistantRepo.threads(4)).rejects.toBeInstanceOf(OnlineOnlyError);
+ await expect(assistantRepo.thread(4, 7)).rejects.toBeInstanceOf(OnlineOnlyError);
+ await expect(assistantRepo.deleteThread(4, 7)).rejects.toBeInstanceOf(OnlineOnlyError);
+ await expect(
+ assistantRepo.send(4, { locale: 'en', timeZone: 'UTC', threadId: null, text: 'Hi' }, signal)
+ ).rejects.toBeInstanceOf(OnlineOnlyError);
+ await expect(assistantRepo.undo(4, 'c1')).rejects.toBeInstanceOf(OnlineOnlyError);
+ await expect(assistantRepo.stop(4)).rejects.toBeInstanceOf(OnlineOnlyError);
+ expect(chat).not.toHaveBeenCalled();
+ expect(threads).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/repo/assistantRepo.ts b/client/src/repo/assistantRepo.ts
new file mode 100644
index 000000000..e958e6f9c
--- /dev/null
+++ b/client/src/repo/assistantRepo.ts
@@ -0,0 +1,95 @@
+import {
+ assistantAddFoundPlaceResponseSchema,
+ assistantThreadListResponseSchema,
+ assistantThreadResponseSchema,
+ collabMessageResponseSchema,
+ type AssistantAddFoundPlaceRequest,
+ type AssistantChatRequest,
+ type AssistantDecisionsRequest,
+ type AssistantShareRequest,
+} from '@trek/shared';
+import { assistantApi } from '../api/assistant';
+import { requireOnline } from './onlineOnly';
+
+/**
+ * The trip assistant's conversations.
+ *
+ * **Deliberately online-only, and no Dexie table.** Every answer comes from a
+ * model on the other side of the server, so there is nothing to do offline,
+ * and a cached copy of a chat would be one more place the trip data the model
+ * read lives on the device. Each call refuses with `OnlineOnlyError` before a
+ * request is made; the view says why instead of hanging on a timeout.
+ *
+ * The planner learns about applied changes the normal way, through the
+ * WebSocket broadcast the tools send, so nothing here touches trip data.
+ */
+export const assistantRepo = {
+ async threads(tripId: number) {
+ requireOnline();
+ return assistantThreadListResponseSchema.parse(await assistantApi.threads(tripId)).threads;
+ },
+
+ async thread(tripId: number, threadId: number) {
+ requireOnline();
+ return assistantThreadResponseSchema.parse(await assistantApi.thread(tripId, threadId));
+ },
+
+ async deleteThread(tripId: number, threadId: number): Promise {
+ requireOnline();
+ await assistantApi.deleteThread(tripId, threadId);
+ },
+
+ /** A new message; resolves once the server accepted it, with the stream of the answer. */
+ async send(tripId: number, body: AssistantChatRequest, signal: AbortSignal) {
+ requireOnline();
+ return assistantApi.chat(tripId, body, signal);
+ },
+
+ /** The decisions on the changes a step holds; the stream carries their results and the rest of the turn. */
+ async decide(tripId: number, threadId: number, body: AssistantDecisionsRequest, signal: AbortSignal) {
+ requireOnline();
+ return assistantApi.decide(tripId, threadId, body, signal);
+ },
+
+ /** A booking file for the chat to read; the stream proposes each booking found in it. */
+ async attach(
+ tripId: number,
+ file: File,
+ fields: { threadId: number | null; locale: string; timeZone: string },
+ signal: AbortSignal
+ ) {
+ requireOnline();
+ const form = new FormData();
+ form.append('file', file);
+ if (fields.threadId !== null) form.append('threadId', String(fields.threadId));
+ form.append('locale', fields.locale);
+ form.append('timeZone', fields.timeZone);
+ return assistantApi.attach(tripId, form, signal);
+ },
+
+ async undo(tripId: number, toolCallId: string): Promise {
+ requireOnline();
+ await assistantApi.undo(tripId, toolCallId);
+ },
+
+ /** A place a search found, held as a change for a day; no model runs for it. */
+ async addFoundPlace(tripId: number, threadId: number, body: AssistantAddFoundPlaceRequest) {
+ requireOnline();
+ return assistantAddFoundPlaceResponseSchema.parse(await assistantApi.addFoundPlace(tripId, threadId, body));
+ },
+
+ /**
+ * An answer posted to the trip chat, as the preview showed it. Online only
+ * like the rest: a chat message sent later would land out of order between
+ * the live ones.
+ */
+ async share(tripId: number, threadId: number, messageId: string, body: AssistantShareRequest) {
+ requireOnline();
+ return collabMessageResponseSchema.parse(await assistantApi.share(tripId, threadId, messageId, body));
+ },
+
+ async stop(tripId: number): Promise {
+ requireOnline();
+ await assistantApi.stop(tripId);
+ },
+};
diff --git a/client/src/store/addonStore.ts b/client/src/store/addonStore.ts
index 60f106d42..7ff504d4a 100644
--- a/client/src/store/addonStore.ts
+++ b/client/src/store/addonStore.ts
@@ -31,6 +31,8 @@ interface AddonState {
toursHikingMap: boolean
/** Tours is on and the admin offers CyclOSM in its planner (BIK-06). */
toursCycleMap: boolean
+ /** The collab chat is on: the admin can switch it off inside the collab addon. */
+ collabChat: boolean
loaded: boolean
loadAddons: () => Promise
isEnabled: (id: string) => boolean
@@ -42,6 +44,7 @@ export const useAddonStore = create((set, get) => ({
toursWanderer: false,
toursHikingMap: false,
toursCycleMap: false,
+ collabChat: true,
loaded: false,
loadAddons: async () => {
@@ -51,6 +54,7 @@ export const useAddonStore = create((set, get) => ({
toursWanderer: !!data.toursWanderer,
toursHikingMap: !!data.toursHikingMap,
toursCycleMap: !!data.toursCycleMap,
+ collabChat: data.collabFeatures?.chat !== false,
loaded: true,
})
} catch {
diff --git a/client/src/store/assistantHintsStore.test.ts b/client/src/store/assistantHintsStore.test.ts
new file mode 100644
index 000000000..69a4e21d6
--- /dev/null
+++ b/client/src/store/assistantHintsStore.test.ts
@@ -0,0 +1,66 @@
+/**
+ * FE-AI-HINTSTORE-001 to FE-AI-HINTSTORE-004
+ *
+ * What one person did with the plan hints of a trip: dismissed and seen ids,
+ * kept in the browser's storage per trip, and still working without it.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useAssistantHintsStore } from './assistantHintsStore';
+
+const INITIAL = useAssistantHintsStore.getState();
+const KEY = 'trek:assistant-hints:7';
+
+beforeEach(() => {
+ window.localStorage.clear();
+ useAssistantHintsStore.setState(INITIAL, true);
+});
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('assistantHintsStore', () => {
+ it('FE-AI-HINTSTORE-001: keeps dismissed and seen ids per trip, each once, and writes them to storage', () => {
+ const { dismiss, markSeen } = useAssistantHintsStore.getState();
+ dismiss(7, 'gap:1:600-780');
+ dismiss(7, 'gap:1:600-780');
+ markSeen(7, ['overlap:1:a', 'overlap:1:a', 'closed:2:p3']);
+ expect(useAssistantHintsStore.getState().byTrip[7]).toEqual({
+ dismissed: ['gap:1:600-780'],
+ seen: ['overlap:1:a', 'closed:2:p3'],
+ });
+ expect(useAssistantHintsStore.getState().byTrip[8]).toBeUndefined();
+ expect(JSON.parse(window.localStorage.getItem(KEY) ?? 'null')).toEqual({
+ dismissed: ['gap:1:600-780'],
+ seen: ['overlap:1:a', 'closed:2:p3'],
+ });
+ });
+
+ it('FE-AI-HINTSTORE-002: reads a trip back from storage on load, and drops what is not an id', () => {
+ window.localStorage.setItem(KEY, JSON.stringify({ dismissed: ['a', 3, null], seen: 'nope' }));
+ useAssistantHintsStore.getState().load(7);
+ expect(useAssistantHintsStore.getState().byTrip[7]).toEqual({ dismissed: ['a'], seen: [] });
+
+ window.localStorage.setItem('trek:assistant-hints:9', '{not json');
+ useAssistantHintsStore.getState().load(9);
+ expect(useAssistantHintsStore.getState().byTrip[9]).toEqual({ dismissed: [], seen: [] });
+ });
+
+ it('FE-AI-HINTSTORE-003: keeps only the most recent hundred ids of a list', () => {
+ const ids = Array.from({ length: 105 }, (_, i) => `gap:1:${i}`);
+ useAssistantHintsStore.getState().markSeen(7, ids);
+ const { seen } = useAssistantHintsStore.getState().byTrip[7];
+ expect(seen).toHaveLength(100);
+ expect(seen[0]).toBe('gap:1:5');
+ });
+
+ it('FE-AI-HINTSTORE-004: works on without storage, for as long as the page is up', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('blocked');
+ });
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new Error('blocked');
+ });
+ useAssistantHintsStore.getState().dismiss(7, 'noStay:2:1');
+ expect(useAssistantHintsStore.getState().byTrip[7]).toEqual({ dismissed: ['noStay:2:1'], seen: [] });
+ });
+});
diff --git a/client/src/store/assistantHintsStore.ts b/client/src/store/assistantHintsStore.ts
new file mode 100644
index 000000000..4f0f6218e
--- /dev/null
+++ b/client/src/store/assistantHintsStore.ts
@@ -0,0 +1,81 @@
+import { create } from 'zustand';
+
+const STORAGE_PREFIX = 'trek:assistant-hints:';
+/** Ids kept per list and trip. A hint whose situation changed has a new id, so old ones only pile up. */
+const MAX_KEPT = 100;
+
+interface HintMarks {
+ /** Hints the user waved away in this trip. */
+ dismissed: readonly string[];
+ /** Hints the user has been shown, so the launcher stops pointing at them. */
+ seen: readonly string[];
+}
+
+const NONE: HintMarks = { dismissed: [], seen: [] };
+
+function ids(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string').slice(-MAX_KEPT) : [];
+}
+
+function readMarks(tripId: number): HintMarks {
+ try {
+ const parsed: unknown = JSON.parse(window.localStorage.getItem(STORAGE_PREFIX + tripId) ?? 'null');
+ if (!parsed || typeof parsed !== 'object') return NONE;
+ const raw = parsed as Record;
+ return { dismissed: ids(raw.dismissed), seen: ids(raw.seen) };
+ } catch {
+ // Storage blocked or the entry unreadable: every hint counts as new.
+ return NONE;
+ }
+}
+
+function writeMarks(tripId: number, marks: HintMarks): void {
+ try {
+ window.localStorage.setItem(STORAGE_PREFIX + tripId, JSON.stringify(marks));
+ } catch {
+ // Storage blocked: the marks still hold while the page is up.
+ }
+}
+
+function withIds(list: readonly string[], added: readonly string[]): readonly string[] {
+ const fresh = [...new Set(added)].filter((id) => !list.includes(id));
+ return fresh.length === 0 ? list : [...list, ...fresh].slice(-MAX_KEPT);
+}
+
+interface AssistantHintsState {
+ /** Per trip, once read from storage. */
+ byTrip: Record;
+ load: (tripId: number) => void;
+ dismiss: (tripId: number, id: string) => void;
+ markSeen: (tripId: number, ids: readonly string[]) => void;
+}
+
+/**
+ * Which plan hints one person has dismissed or seen, per trip, on this
+ * device. A view preference like a hidden tour: nobody else sees it and it
+ * never touches the trip. Mirrored to the browser's storage, so a reload
+ * keeps it; without storage it lasts as long as the page.
+ */
+export const useAssistantHintsStore = create()((set, get) => {
+ const update = (tripId: number, next: (marks: HintMarks) => HintMarks) => {
+ const before = get().byTrip[tripId] ?? readMarks(tripId);
+ const after = next(before);
+ if (after === before && get().byTrip[tripId]) return;
+ if (after !== before) writeMarks(tripId, after);
+ set((s) => ({ byTrip: { ...s.byTrip, [tripId]: after } }));
+ };
+ return {
+ byTrip: {},
+ load: (tripId) => update(tripId, (marks) => marks),
+ dismiss: (tripId, id) =>
+ update(tripId, (marks) => {
+ const dismissed = withIds(marks.dismissed, [id]);
+ return dismissed === marks.dismissed ? marks : { ...marks, dismissed };
+ }),
+ markSeen: (tripId, seenIds) =>
+ update(tripId, (marks) => {
+ const seen = withIds(marks.seen, seenIds);
+ return seen === marks.seen ? marks : { ...marks, seen };
+ }),
+ };
+});
diff --git a/client/src/store/assistantMapModel.test.ts b/client/src/store/assistantMapModel.test.ts
new file mode 100644
index 000000000..4550356e6
--- /dev/null
+++ b/client/src/store/assistantMapModel.test.ts
@@ -0,0 +1,181 @@
+/**
+ * FE-AI-MAPMODEL-001 to FE-AI-MAPMODEL-009
+ *
+ * What the map makes of the assistant's chat, without React or a store: which
+ * changes stand on the map as ghosts and until when, the box a running step
+ * searches, the days a block touches, the "before" plan laid over the view,
+ * and how far the floating window reaches over the map.
+ */
+import type { AssistantChangeEffect, AssistantMessage, AssistantMessagePart } from '@trek/shared';
+import { describe, expect, it } from 'vitest';
+import type { Assignment, AssignmentsMap } from '../types';
+import {
+ activityFocus,
+ blockDays,
+ blockKeyOf,
+ dayPoints,
+ ghostCandidates,
+ liveGhosts,
+ overlaySnapshot,
+ snapshotDays,
+ windowCover,
+} from './assistantMapModel';
+
+const NEW_PLACE: AssistantChangeEffect = {
+ kind: 'add',
+ dayId: 3,
+ fromDayId: null,
+ placeId: null,
+ assignmentId: null,
+ place: { name: 'Pantheon', lat: 41.9, lng: 12.48, categoryId: 2 },
+};
+const MOVE: AssistantChangeEffect = {
+ kind: 'move',
+ dayId: 4,
+ fromDayId: 3,
+ placeId: 20,
+ assignmentId: 200,
+ place: null,
+};
+const REMOVE: AssistantChangeEffect = { ...MOVE, kind: 'remove', fromDayId: null };
+
+type Approval = { approved?: boolean; resolution?: 'cancelled' | 'expired' };
+
+function call(toolCallId: string, approval: Approval, result?: unknown): AssistantMessagePart[] {
+ return [{ type: 'tool-call', toolCallId, toolName: 'x', args: {}, approval: { id: toolCallId, ...approval }, result }];
+}
+
+function change(toolCallId: string, effect: AssistantChangeEffect): AssistantMessagePart {
+ return {
+ type: 'data-trek-change',
+ data: {
+ toolCallId,
+ verbKey: 'addPlace',
+ subject: 'x',
+ rows: [],
+ dayId: null,
+ placeIds: [],
+ undoable: true,
+ effect,
+ },
+ };
+}
+
+function message(parts: AssistantMessagePart[]): AssistantMessage {
+ return { id: 'msg-1', role: 'assistant', parts, status: 'awaiting_approval', createdAt: '2026-10-21T08:00:00.000Z' };
+}
+
+function visit(id: number, dayId: number, lat = 41, lng = 12): Assignment {
+ const place = { id: id * 10, name: `P${id}`, lat, lng };
+ return { id, day_id: dayId, place_id: id * 10, order_index: 0, place } as Assignment;
+}
+
+describe('assistantMapModel: ghosts', () => {
+ it('FE-AI-MAPMODEL-001: a change waiting for a decision is a ghost; a removal and a decided-against one are not', () => {
+ const messages = [
+ message([
+ ...call('a', {}),
+ change('a', NEW_PLACE),
+ ...call('b', {}),
+ change('b', REMOVE),
+ ...call('c', { approved: false }),
+ change('c', MOVE),
+ ...call('d', { resolution: 'expired' }),
+ change('d', MOVE),
+ ...call('e', {}),
+ ]),
+ ];
+ expect(ghostCandidates(messages)).toEqual([
+ { toolCallId: 'a', effect: NEW_PLACE, status: 'waiting', placeId: null },
+ ]);
+ });
+
+ it('FE-AI-MAPMODEL-002: an applied new place stays a ghost until the trip holds the place it made', () => {
+ const running = [message([...call('a', { approved: true }), change('a', NEW_PLACE)])];
+ expect(liveGhosts(ghostCandidates(running), new Set())).toHaveLength(1);
+
+ const applied = [
+ message([
+ ...call('a', { approved: true }, { id: 77 }),
+ change('a', NEW_PLACE),
+ { type: 'data-trek-applied', data: { toolCallId: 'a', entity: 'place', ids: [77], dayId: 3, undoUntil: null } },
+ ]),
+ ];
+ const candidates = ghostCandidates(applied);
+ expect(candidates[0]).toMatchObject({ status: 'applied', placeId: 77 });
+ expect(liveGhosts(candidates, new Set())).toHaveLength(1);
+ expect(liveGhosts(candidates, new Set([77]))).toEqual([]);
+ });
+
+ it('FE-AI-MAPMODEL-003: an applied change to a place that exists, or one that failed, leaves no ghost', () => {
+ const messages = [
+ message([
+ ...call('a', { approved: true }),
+ change('a', MOVE),
+ ...call('b', { approved: true }, 'boom'),
+ change('b', NEW_PLACE),
+ ]),
+ ];
+ expect(liveGhosts(ghostCandidates(messages), new Set())).toEqual([]);
+ });
+
+ it('FE-AI-MAPMODEL-004: a block is named by its first call', () => {
+ expect(blockKeyOf(['x', 'y'])).toBe('x');
+ expect(blockKeyOf([])).toBe('');
+ });
+});
+
+describe('assistantMapModel: areas and days', () => {
+ it('FE-AI-MAPMODEL-005: a box is its corners; a point with a radius becomes the box around it', () => {
+ expect(activityFocus(null)).toEqual([]);
+ expect(activityFocus({ type: 'bbox', south: 1, west: 2, north: 3, east: 4 })).toEqual([
+ [1, 2],
+ [3, 4],
+ ]);
+ const [[s, w], [n, e]] = activityFocus({ type: 'point', lat: 0, lng: 10, radiusM: 1113.2 });
+ expect(n - s).toBeCloseTo(0.02, 4);
+ expect(e - w).toBeCloseTo(0.02, 4);
+ expect((s + n) / 2).toBeCloseTo(0, 6);
+ });
+
+ it('FE-AI-MAPMODEL-006: a block touches the days its changes land on and come from, each once', () => {
+ expect(blockDays([NEW_PLACE, MOVE, MOVE])).toEqual([3, 4]);
+ const plan: AssignmentsMap = { 3: [visit(1, 3, 41, 12)], 4: [visit(2, 4, 42, 13)] };
+ expect(dayPoints(plan, [3, 4, 9])).toEqual([
+ [41, 12],
+ [42, 13],
+ ]);
+ });
+});
+
+describe('assistantMapModel: before and after', () => {
+ it('FE-AI-MAPMODEL-007: the snapshot copies only the days asked for', () => {
+ const plan: AssignmentsMap = { 3: [visit(1, 3)], 4: [visit(2, 4)] };
+ const snapshot = snapshotDays(plan, [3]);
+ expect(Object.keys(snapshot)).toEqual(['3']);
+ expect(snapshot[3]).not.toBe(plan[3]);
+ });
+
+ it('FE-AI-MAPMODEL-008: "before" shows a stop the block took away, and keeps a stop the view hides hidden', () => {
+ const hotel = visit(5, 3);
+ const removed = visit(6, 3);
+ const kept = visit(7, 3);
+ const stored: AssignmentsMap = { 3: [hotel, kept], 4: [visit(8, 4)] };
+ const view: AssignmentsMap = { 3: [kept], 4: [visit(8, 4)] };
+ const before = overlaySnapshot(view, stored, { 3: [hotel, removed, kept] });
+ expect(before[3]?.map((v) => v.id)).toEqual([6, 7]);
+ expect(before[4]).toBe(view[4]);
+ });
+});
+
+describe('assistantMapModel: the window over the map', () => {
+ it('FE-AI-MAPMODEL-009: covers the side it sits on, mirrored right to left, and nothing when it is too wide', () => {
+ const rect = { x: 900, y: 80, width: 360, height: 500 };
+ expect(windowCover(rect, 1280, false)).toEqual({ side: 'right', px: 404 });
+ expect(windowCover({ ...rect, x: 20 }, 1280, false)).toEqual({ side: 'left', px: 404 });
+ // In Arabic x counts from the right edge: 20 from the right is the right side.
+ expect(windowCover({ ...rect, x: 20 }, 1280, true)).toEqual({ side: 'right', px: 404 });
+ expect(windowCover({ ...rect, width: 900 }, 1280, false)).toBeNull();
+ expect(windowCover(null, 1280, false)).toBeNull();
+ });
+});
diff --git a/client/src/store/assistantMapModel.ts b/client/src/store/assistantMapModel.ts
new file mode 100644
index 000000000..27373622c
--- /dev/null
+++ b/client/src/store/assistantMapModel.ts
@@ -0,0 +1,205 @@
+import type { AssistantActivityArea, AssistantApplied, AssistantChangeEffect, AssistantMessage } from '@trek/shared';
+import type { Assignment, AssignmentsMap } from '../types';
+
+/**
+ * What the map makes of the assistant's chat: the suggestions still waiting to
+ * be decided, the area a running step searches, and the days a block of
+ * changes touched. Pure and React-free, so the store and the planner's map
+ * hook read the same answers and the tests need neither.
+ */
+
+/** [lat, lng], the order both map renderers take. */
+export type LatLng = [number, number];
+
+/** Stops by day: the trip store's map, or a snapshot of some of its days. */
+interface DayPlans {
+ readonly [dayId: number]: readonly Assignment[] | undefined;
+}
+
+/** One block of changes is named by its first call, the same key the change card builds from its items. */
+export function blockKeyOf(toolCallIds: readonly string[]): string {
+ return toolCallIds[0] ?? '';
+}
+
+/** The effect each change of the thread has on the day plan, by its call. */
+export function changeEffects(messages: readonly AssistantMessage[]): Map {
+ const effects = new Map();
+ for (const m of messages) {
+ for (const p of m.parts) {
+ if (p.type === 'data-trek-change' && p.data.effect) effects.set(p.data.toolCallId, p.data.effect);
+ }
+ }
+ return effects;
+}
+
+function receiptsOf(messages: readonly AssistantMessage[]): Map {
+ const receipts = new Map();
+ for (const m of messages) {
+ for (const p of m.parts) if (p.type === 'data-trek-applied') receipts.set(p.data.toolCallId, p.data);
+ }
+ return receipts;
+}
+
+/**
+ * A change the map may draw before it exists: its call, what it does to the
+ * plan, and how far it got. `waiting` is undecided, `running` applied but not
+ * answered yet, `applied` done, with the place it made when it made one.
+ */
+export interface GhostCandidate {
+ toolCallId: string;
+ effect: AssistantChangeEffect;
+ status: 'waiting' | 'running' | 'applied';
+ placeId: number | null;
+}
+
+/**
+ * Every change of the thread that could stand on the map as a ghost, in the
+ * order of the chat. Discarded, expired and failed changes are not, and
+ * neither is a removal: what goes is already on the map.
+ */
+export function ghostCandidates(messages: readonly AssistantMessage[]): GhostCandidate[] {
+ const effects = changeEffects(messages);
+ if (effects.size === 0) return [];
+ const receipts = receiptsOf(messages);
+ const candidates: GhostCandidate[] = [];
+ for (const m of messages) {
+ for (const p of m.parts) {
+ if (p.type !== 'tool-call' || !p.approval || p.approval.resolution) continue;
+ const effect = effects.get(p.toolCallId);
+ if (!effect || effect.kind === 'remove' || p.approval.approved === false) continue;
+ const receipt = receipts.get(p.toolCallId);
+ if (p.approval.approved === undefined) {
+ candidates.push({ toolCallId: p.toolCallId, effect, status: 'waiting', placeId: null });
+ } else if (receipt) {
+ candidates.push({ toolCallId: p.toolCallId, effect, status: 'applied', placeId: receipt.ids[0] ?? null });
+ } else if (p.result === undefined) {
+ candidates.push({ toolCallId: p.toolCallId, effect, status: 'running', placeId: null });
+ }
+ }
+ }
+ return candidates;
+}
+
+/**
+ * The candidates the map draws as ghosts now. One waiting for a decision
+ * always is. A new place the user applied stays one until the trip holds the
+ * place it made (its id comes with the receipt, the place itself with the
+ * broadcast), so the pin never blinks out between the two. An applied change
+ * to a place that already existed is that place's own pin from then on.
+ */
+export function liveGhosts(
+ candidates: readonly GhostCandidate[],
+ knownPlaceIds: ReadonlySet
+): GhostCandidate[] {
+ return candidates.filter((c) => {
+ if (c.status === 'waiting') return true;
+ if (!c.effect.place) return false;
+ return c.status === 'running' || (c.placeId !== null && !knownPlaceIds.has(c.placeId));
+ });
+}
+
+const METERS_PER_DEGREE = 111_320;
+
+/** The corners of the area a running step searches, or nothing when it names none. */
+export function activityFocus(area: AssistantActivityArea | null): LatLng[] {
+ if (!area) return [];
+ if (area.type === 'bbox') {
+ return [
+ [area.south, area.west],
+ [area.north, area.east],
+ ];
+ }
+ const dLat = area.radiusM / METERS_PER_DEGREE;
+ // Near a pole a degree of longitude shrinks to nothing; the floor keeps the box finite.
+ const dLng = area.radiusM / (METERS_PER_DEGREE * Math.max(Math.cos((area.lat * Math.PI) / 180), 0.01));
+ return [
+ [area.lat - dLat, area.lng - dLng],
+ [area.lat + dLat, area.lng + dLng],
+ ];
+}
+
+/** The days a set of changes touches: where each lands and where a move comes from. */
+export function blockDays(effects: readonly AssistantChangeEffect[]): number[] {
+ const days = new Set();
+ for (const e of effects) {
+ days.add(e.dayId);
+ if (e.fromDayId !== null) days.add(e.fromDayId);
+ }
+ return [...days];
+}
+
+/** The positions of the stops on the given days. */
+export function dayPoints(assignments: DayPlans, dayIds: readonly number[]): LatLng[] {
+ const points: LatLng[] = [];
+ for (const dayId of dayIds) {
+ for (const visit of assignments[dayId] ?? []) {
+ const lat = visit.place?.lat;
+ const lng = visit.place?.lng;
+ if (typeof lat === 'number' && typeof lng === 'number') points.push([lat, lng]);
+ }
+ }
+ return points;
+}
+
+/** The plan of the given days as it stands, for "before" once the changes ran. */
+export function snapshotDays(assignments: DayPlans, dayIds: readonly number[]): Record {
+ const snapshot: Record = {};
+ for (const dayId of dayIds) snapshot[dayId] = [...(assignments[dayId] ?? [])];
+ return snapshot;
+}
+
+/**
+ * The plan the map shows while "before" is on: the snapshot's days laid over
+ * the planner's view of the rest.
+ *
+ * The snapshot is taken from the store, the view hides some stops (hotel
+ * stops, service stops the traveller switched off). A stop still stored on its
+ * day but missing from the view is one of those and stays hidden; a stop no
+ * longer stored there is one the block took away and shows again.
+ */
+export function overlaySnapshot(
+ view: AssignmentsMap,
+ stored: AssignmentsMap,
+ snapshot: Readonly>
+): AssignmentsMap {
+ const next: AssignmentsMap = { ...view };
+ for (const [day, visits] of Object.entries(snapshot)) {
+ const storedIds = new Set((stored[day] ?? []).map((v) => v.id));
+ const shownIds = new Set((view[day] ?? []).map((v) => v.id));
+ next[day] = visits.filter((v) => !storedIds.has(v.id) || shownIds.has(v.id));
+ }
+ return next;
+}
+
+/** Where the floating window sits, as the store keeps it: `x` counts from the inline start. */
+export interface WindowRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/** The screen edge something covers the map from, and how far in it reaches, in CSS pixels. */
+interface MapCover {
+ side: 'left' | 'right';
+ px: number;
+}
+
+/** Room left between the window and what the map frames beside it. */
+const WINDOW_GAP = 24;
+
+/**
+ * How far the floating window reaches over the map from the screen edge it is
+ * nearer to, so a fit lands in the part still visible. Screen geometry, so the
+ * window's inline-start offset is turned into a distance from the physical
+ * edge first. A window that takes most of the width leaves nothing worth
+ * framing beside it and covers nothing here.
+ */
+export function windowCover(rect: WindowRect | null, viewportWidth: number, rtl: boolean): MapCover | null {
+ if (!rect || viewportWidth <= 0 || rect.width > viewportWidth * 0.6) return null;
+ const fromScreenStart = rtl ? viewportWidth - rect.x - rect.width : rect.x;
+ if (fromScreenStart + rect.width / 2 > viewportWidth / 2) {
+ return { side: 'right', px: Math.max(0, viewportWidth - fromScreenStart + WINDOW_GAP) };
+ }
+ return { side: 'left', px: Math.max(0, fromScreenStart + rect.width + WINDOW_GAP) };
+}
diff --git a/client/src/store/assistantMessages.cards.test.ts b/client/src/store/assistantMessages.cards.test.ts
new file mode 100644
index 000000000..3b18eb6fd
--- /dev/null
+++ b/client/src/store/assistantMessages.cards.test.ts
@@ -0,0 +1,72 @@
+/**
+ * FE-AI-MSG-CARDS-001 to 002
+ *
+ * The places and the weather a step found are stored parts of the answer,
+ * like a change card: they land in the message being written, and a chunk
+ * sent again for the same search step replaces its part instead of adding a
+ * second strip.
+ */
+import type { AssistantMessage, AssistantPlaces } from '@trek/shared';
+import { describe, expect, it } from 'vitest';
+import { applyChunk } from './assistantMessages';
+
+const NOW = () => '2026-10-21T08:00:00.000Z';
+
+const PLACES: AssistantPlaces = {
+ toolCallId: 'search-1',
+ places: [
+ {
+ ref: 'search-1:0',
+ name: 'Da Enzo',
+ address: null,
+ lat: 41.88,
+ lng: 12.47,
+ kind: 'food',
+ rating: 4.6,
+ openNow: null,
+ googlePlaceId: null,
+ osmId: null,
+ photoKey: null,
+ inTripPlaceId: null,
+ },
+ ],
+ more: 3,
+};
+
+function started(): AssistantMessage[] {
+ return applyChunk([], { type: 'start', messageId: 'msg-1' }, NOW);
+}
+
+describe('assistantMessages: cards in an answer', () => {
+ it('FE-AI-MSG-CARDS-001: the places of one step are one part, replaced when the step sends them again', () => {
+ let messages = applyChunk(started(), { type: 'data-trek-places', data: PLACES }, NOW);
+ messages = applyChunk(messages, { type: 'data-trek-places', data: { ...PLACES, more: 0 } }, NOW);
+ messages = applyChunk(messages, { type: 'data-trek-places', data: { ...PLACES, toolCallId: 'search-2' } }, NOW);
+ const parts = messages[0]?.parts ?? [];
+ expect(parts.map((p) => (p.type === 'data-trek-places' ? [p.data.toolCallId, p.data.more] : p.type))).toEqual([
+ ['search-1', 0],
+ ['search-2', 3],
+ ]);
+ });
+
+ it('FE-AI-MSG-CARDS-002: the weather lands in the answer; the live activity never does', () => {
+ let messages = applyChunk(
+ started(),
+ {
+ type: 'data-trek-weather',
+ data: {
+ days: [
+ { dayId: 3, date: '2026-10-22', main: 'Clear', tempMax: 21, tempMin: 12, rainPct: 10, kind: 'forecast' },
+ ],
+ },
+ },
+ NOW
+ );
+ messages = applyChunk(
+ messages,
+ { type: 'data-trek-activity', data: { toolCallId: 'w', label: 'Rome', area: null }, transient: true },
+ NOW
+ );
+ expect(messages[0]?.parts.map((p) => p.type)).toEqual(['data-trek-weather']);
+ });
+});
diff --git a/client/src/store/assistantMessages.test.ts b/client/src/store/assistantMessages.test.ts
new file mode 100644
index 000000000..e90dc33cc
--- /dev/null
+++ b/client/src/store/assistantMessages.test.ts
@@ -0,0 +1,199 @@
+/**
+ * FE-AI-MSG-001 to FE-AI-MSG-009
+ *
+ * The stream is folded into messages of the shape the server stores, so a
+ * chat that was streamed and one that was reloaded render the same. The
+ * shared fixture is the reference turn: a reading tool, some text, and one
+ * held change waiting for the user.
+ */
+import { uiStreamChunkSchema, type AssistantMessage, type UiStreamChunk } from '@trek/shared';
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+import { applyChunk, closeUndo, markDecisions, pendingToolCallIds } from './assistantMessages';
+
+const NOW = '2026-10-21T08:00:00.000Z';
+const now = () => NOW;
+const WRITE_ID = '3f2b8c1e-6d4a-4f7b-9e2c-5a1d8b7c6e40';
+
+const FIXTURE: UiStreamChunk[] = readFileSync(
+ resolve(dirname(fileURLToPath(import.meta.url)), '../../../shared/src/assistant/__fixtures__/ui-stream.v1.txt'),
+ 'utf8'
+)
+ .split('\n\n')
+ .map((event) => event.replace(/^data: /, '').trim())
+ .filter((data) => data && data !== '[DONE]')
+ .map((data) => uiStreamChunkSchema.parse(JSON.parse(data)));
+
+const QUESTION: AssistantMessage = {
+ id: 'local-1',
+ role: 'user',
+ parts: [{ type: 'text', text: 'Fit the Golden Pavilion into Friday' }],
+ status: 'complete',
+ createdAt: NOW,
+};
+
+const fold = (messages: AssistantMessage[], chunks: UiStreamChunk[]) =>
+ chunks.reduce((acc, chunk) => applyChunk(acc, chunk, now), messages);
+
+const afterFixture = () => fold([QUESTION], FIXTURE);
+
+describe('applyChunk', () => {
+ it('FE-AI-MSG-001: folds the fixture into one assistant message that waits for a decision', () => {
+ const messages = afterFixture();
+ expect(messages).toHaveLength(2);
+ expect(messages[0]).toBe(QUESTION);
+ const answer = messages[1];
+ expect(answer).toMatchObject({ id: 'msg-12', role: 'assistant', status: 'awaiting_approval', createdAt: NOW });
+ expect(answer?.parts.map((p) => p.type)).toEqual(['tool-call', 'text', 'tool-call', 'data-trek-change']);
+ expect(answer?.parts[0]).toMatchObject({
+ type: 'tool-call',
+ toolCallId: 'toolu_01',
+ toolName: 'get_trip_summary',
+ isError: false,
+ result: { title: 'Kyoto in June' },
+ });
+ expect(answer?.parts[1]).toEqual({
+ type: 'text',
+ text: 'Friday has room in the afternoon. I would add Kinkaku-ji there.',
+ });
+ expect(answer?.parts[2]).toMatchObject({
+ toolCallId: WRITE_ID,
+ toolName: 'create_and_assign_place',
+ args: { dayId: 32, name: 'Kinkaku-ji' },
+ approval: { id: WRITE_ID },
+ });
+ expect(pendingToolCallIds(messages)).toEqual([WRITE_ID]);
+ });
+
+ it('FE-AI-MSG-002: the decisions stream reports on the held call and continues the same message', () => {
+ const decided = markDecisions(afterFixture(), [{ toolCallId: WRITE_ID, approved: true }]);
+ const applied = { toolCallId: WRITE_ID, entity: 'place' as const, ids: [55], dayId: 32, undoUntil: NOW };
+ const messages = fold(decided, [
+ { type: 'start', messageId: 'msg-12' },
+ { type: 'tool-output-available', toolCallId: WRITE_ID, output: { place: { id: 55 } } },
+ { type: 'data-trek-applied', data: applied },
+ { type: 'text-start', id: 'txt-1' },
+ { type: 'text-delta', id: 'txt-1', delta: 'Done.' },
+ { type: 'finish', finishReason: 'stop' },
+ ]);
+ expect(messages).toHaveLength(2);
+ const answer = messages[1];
+ expect(answer?.status).toBe('complete');
+ expect(answer?.parts[2]).toMatchObject({
+ approval: { id: WRITE_ID, approved: true },
+ result: { place: { id: 55 } },
+ });
+ expect(answer?.parts.slice(-2)).toEqual([
+ { type: 'data-trek-applied', data: applied },
+ { type: 'text', text: 'Done.' },
+ ]);
+ });
+
+ it('FE-AI-MSG-003: a new message id opens a new message, and the receipt still lands next to its call', () => {
+ const applied = { toolCallId: WRITE_ID, entity: 'place' as const, ids: [55], dayId: 32, undoUntil: null };
+ const messages = fold(markDecisions(afterFixture(), [{ toolCallId: WRITE_ID, approved: true }]), [
+ { type: 'start', messageId: 'msg-13' },
+ { type: 'data-trek-applied', data: applied },
+ { type: 'text-delta', id: 't', delta: 'Added.' },
+ { type: 'finish' },
+ ]);
+ expect(messages.map((m) => m.id)).toEqual(['local-1', 'msg-12', 'msg-13']);
+ expect(messages[1]?.status).toBe('complete');
+ expect(messages[1]?.parts[messages[1].parts.length - 1]).toEqual({ type: 'data-trek-applied', data: applied });
+ expect(messages[2]?.parts).toEqual([{ type: 'text', text: 'Added.' }]);
+ });
+
+ it('FE-AI-MSG-004: a discarded change is stored as the server stores it', () => {
+ const messages = fold(afterFixture(), [{ type: 'tool-output-denied', toolCallId: WRITE_ID }]);
+ expect(messages[1]?.parts[2]).toMatchObject({
+ result: { rejected: true },
+ approval: { id: WRITE_ID, approved: false },
+ });
+ expect(pendingToolCallIds(messages)).toEqual([]);
+ });
+
+ it('FE-AI-MSG-005: a failed tool keeps its error text and flag', () => {
+ const messages = fold(
+ [QUESTION],
+ [
+ { type: 'start', messageId: 'm' },
+ { type: 'tool-input-available', toolCallId: 'r1', toolName: 'get_weather', input: { date: '2026-06-12' } },
+ { type: 'tool-output-error', toolCallId: 'r1', errorText: 'No forecast that far ahead' },
+ ]
+ );
+ expect(messages[1]?.parts).toEqual([
+ {
+ type: 'tool-call',
+ toolCallId: 'r1',
+ toolName: 'get_weather',
+ args: { date: '2026-06-12' },
+ result: 'No forecast that far ahead',
+ isError: true,
+ },
+ ]);
+ });
+
+ it('FE-AI-MSG-006: error and abort mark the message being written; a finish keeps the error', () => {
+ const errored = fold(
+ [QUESTION],
+ [{ type: 'start', messageId: 'm' }, { type: 'error', errorText: 'max_steps' }, { type: 'finish' }]
+ );
+ expect(errored[1]?.status).toBe('error');
+ const aborted = fold([QUESTION], [{ type: 'start', messageId: 'm' }, { type: 'abort' }]);
+ expect(aborted[1]?.status).toBe('aborted');
+ // Nothing was opened yet: there is no message to mark.
+ expect(fold([QUESTION], [{ type: 'error', errorText: 'busy' }])).toEqual([QUESTION]);
+ });
+
+ it('FE-AI-MSG-007: content without a start opens a message rather than writing into the question', () => {
+ const messages = fold([QUESTION], [{ type: 'text-delta', id: 't', delta: 'Hi' }]);
+ expect(messages).toHaveLength(2);
+ expect(messages[1]).toMatchObject({ role: 'assistant', parts: [{ type: 'text', text: 'Hi' }] });
+ expect(messages[0]).toBe(QUESTION);
+ });
+
+ it('FE-AI-MSG-008: a repeated change preview replaces the earlier one; unknown calls change nothing', () => {
+ const fixtureChange = FIXTURE.find((c) => c.type === 'data-trek-change');
+ if (fixtureChange?.type !== 'data-trek-change') throw new Error('fixture has no change');
+ const messages = fold(afterFixture(), [
+ { type: 'data-trek-change', data: { ...fixtureChange.data, subject: 'Kinkaku-ji (Golden Pavilion)' } },
+ ]);
+ const previews = messages[1]?.parts.filter((p) => p.type === 'data-trek-change') ?? [];
+ expect(previews).toHaveLength(1);
+ expect(previews[0]).toMatchObject({ data: { subject: 'Kinkaku-ji (Golden Pavilion)' } });
+ const before = afterFixture();
+ expect(applyChunk(before, { type: 'tool-output-available', toolCallId: 'nope', output: 1 }, now)).toBe(before);
+ });
+});
+
+describe('markDecisions and closeUndo', () => {
+ it('FE-AI-MSG-009: decisions mark only the named calls; closeUndo ends the undo window', () => {
+ const second = {
+ type: 'tool-call' as const,
+ toolCallId: 'c2',
+ toolName: 'create_todo',
+ args: {},
+ approval: { id: 'c2' },
+ };
+ const base = afterFixture();
+ const two = [
+ base[0] as AssistantMessage,
+ { ...(base[1] as AssistantMessage), parts: [...(base[1]?.parts ?? []), second] },
+ ];
+
+ const partly = markDecisions(two, [{ toolCallId: 'c2', approved: false }]);
+ expect(pendingToolCallIds(partly)).toEqual([WRITE_ID]);
+ expect(partly[1]?.status).toBe('awaiting_approval');
+ expect(markDecisions(two, [{ toolCallId: 'other', approved: true }])).toEqual(two);
+
+ const applied = { toolCallId: WRITE_ID, entity: 'place' as const, ids: [55], dayId: 32, undoUntil: NOW };
+ const withReceipt = fold(two, [{ type: 'data-trek-applied', data: applied }]);
+ const closed = closeUndo(withReceipt, WRITE_ID);
+ expect(closed[1]?.parts.find((p) => p.type === 'data-trek-applied')).toEqual({
+ type: 'data-trek-applied',
+ data: { ...applied, undoUntil: null },
+ });
+ });
+});
diff --git a/client/src/store/assistantMessages.ts b/client/src/store/assistantMessages.ts
new file mode 100644
index 000000000..bace7d241
--- /dev/null
+++ b/client/src/store/assistantMessages.ts
@@ -0,0 +1,218 @@
+import type {
+ AssistantApplied,
+ AssistantDecision,
+ AssistantMessage,
+ AssistantMessagePart,
+ UiStreamChunk,
+} from '@trek/shared';
+
+/**
+ * Folding the assistant's stream into the messages of a thread. Pure and
+ * React-free: the store runs it per chunk, and the messages it produces have
+ * the same shape the server stores and `GET threads/:id` answers, so a
+ * reloaded thread and a streamed one render through the same code.
+ *
+ * The message being written is the last one, an assistant message opened by
+ * `start`. Results of tool calls, decisions and applied changes are matched
+ * by `toolCallId` across the thread instead, because the decisions stream
+ * reports on calls an earlier message holds.
+ */
+
+type ToolCallPart = Extract;
+
+/** Whether a tool call still waits for the user to apply or discard it. */
+function isAwaitingDecision(part: AssistantMessagePart): part is ToolCallPart {
+ return (
+ part.type === 'tool-call' &&
+ part.approval !== undefined &&
+ part.approval.approved === undefined &&
+ part.approval.resolution === undefined
+ );
+}
+
+/** The calls of the thread that wait for a decision, in order; a decisions request has to name all of them. */
+export function pendingToolCallIds(messages: readonly AssistantMessage[]): string[] {
+ return messages.flatMap((m) => m.parts.filter(isAwaitingDecision).map((p) => p.toolCallId));
+}
+
+export function applyChunk(
+ messages: AssistantMessage[],
+ chunk: UiStreamChunk,
+ now: () => string = () => new Date().toISOString()
+): AssistantMessage[] {
+ switch (chunk.type) {
+ case 'start':
+ return openMessage(messages, chunk.messageId, now);
+ case 'text-start':
+ return withCurrent(messages, now, (parts) => [...parts, { type: 'text', text: '' }]);
+ case 'text-delta':
+ return withCurrent(messages, now, (parts) => appendText(parts, chunk.delta));
+ case 'data-trek-change':
+ return withCurrent(messages, now, (parts) => [
+ ...parts.filter((p) => !(p.type === 'data-trek-change' && p.data.toolCallId === chunk.data.toolCallId)),
+ { type: 'data-trek-change', data: chunk.data },
+ ]);
+ case 'data-trek-applied':
+ return placeApplied(messages, now, chunk.data);
+ case 'data-trek-places':
+ // One part per search step; a repeated chunk for the same step replaces it.
+ return withCurrent(messages, now, (parts) => [
+ ...parts.filter((p) => !(p.type === 'data-trek-places' && p.data.toolCallId === chunk.data.toolCallId)),
+ { type: 'data-trek-places', data: chunk.data },
+ ]);
+ case 'data-trek-weather':
+ return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-weather', data: chunk.data }]);
+ case 'finish':
+ return withStatus(messages, (m) =>
+ m.status === 'error' ? m.status : m.parts.some(isAwaitingDecision) ? 'awaiting_approval' : 'complete'
+ );
+ case 'error':
+ return withStatus(messages, () => 'error');
+ case 'abort':
+ return withStatus(messages, () => 'aborted');
+ default:
+ return applyToolChunk(messages, chunk, now);
+ }
+}
+
+function applyToolChunk(messages: AssistantMessage[], chunk: UiStreamChunk, now: () => string): AssistantMessage[] {
+ switch (chunk.type) {
+ case 'tool-input-start':
+ return upsertCall(messages, now, chunk.toolCallId, chunk.toolName, (p) => p);
+ case 'tool-input-available':
+ return upsertCall(messages, now, chunk.toolCallId, chunk.toolName, (p) => ({ ...p, args: chunk.input }));
+ case 'tool-approval-request':
+ return updateCall(messages, chunk.toolCallId, (p) => ({ ...p, approval: { id: chunk.approvalId } }));
+ case 'tool-output-available':
+ return updateCall(messages, chunk.toolCallId, (p) => ({ ...p, result: chunk.output, isError: false }));
+ case 'tool-output-error':
+ return updateCall(messages, chunk.toolCallId, (p) => ({ ...p, result: chunk.errorText, isError: true }));
+ case 'tool-output-denied':
+ // The shape the server stores for a discarded change.
+ return updateCall(messages, chunk.toolCallId, (p) => ({
+ ...p,
+ result: { rejected: true },
+ approval: { ...(p.approval ?? { id: p.toolCallId }), approved: false },
+ }));
+ default:
+ // start-step, finish-step, text-end, and the thread, quota and live activity data the store reads itself.
+ return messages;
+ }
+}
+
+/** Marks the decided calls before the request goes out, so a card cannot be decided twice. */
+export function markDecisions(
+ messages: AssistantMessage[],
+ decisions: readonly AssistantDecision[]
+): AssistantMessage[] {
+ const approved = new Map(decisions.map((d) => [d.toolCallId, d.approved]));
+ return messages.map((m) => {
+ if (!m.parts.some((p) => isAwaitingDecision(p) && approved.has(p.toolCallId))) return m;
+ const parts = m.parts.map((p) => {
+ if (!isAwaitingDecision(p)) return p;
+ const decision = approved.get(p.toolCallId);
+ return decision === undefined ? p : { ...p, approval: { id: p.toolCallId, ...p.approval, approved: decision } };
+ });
+ const status = m.status === 'awaiting_approval' && !parts.some(isAwaitingDecision) ? 'complete' : m.status;
+ return { ...m, parts, status };
+ });
+}
+
+/** Closes the undo window of an applied change, after it was undone or can no longer be. */
+export function closeUndo(messages: AssistantMessage[], toolCallId: string): AssistantMessage[] {
+ return messages.map((m) =>
+ m.parts.some((p) => p.type === 'data-trek-applied' && p.data.toolCallId === toolCallId)
+ ? {
+ ...m,
+ parts: m.parts.map((p) =>
+ p.type === 'data-trek-applied' && p.data.toolCallId === toolCallId
+ ? { ...p, data: { ...p.data, undoUntil: null } }
+ : p
+ ),
+ }
+ : m
+ );
+}
+
+function emptyAssistant(id: string, createdAt: string): AssistantMessage {
+ return { id, role: 'assistant', parts: [], status: 'complete', createdAt };
+}
+
+/** Opens the assistant message `start` names; the decisions stream may continue the one that is already last. */
+function openMessage(messages: AssistantMessage[], id: string, now: () => string): AssistantMessage[] {
+ const last = messages[messages.length - 1];
+ if (last?.role === 'assistant' && last.id === id) return messages;
+ return [...messages, emptyAssistant(id, now())];
+}
+
+function appendText(parts: AssistantMessagePart[], delta: string): AssistantMessagePart[] {
+ const last = parts[parts.length - 1];
+ if (last?.type === 'text') return [...parts.slice(0, -1), { type: 'text', text: last.text + delta }];
+ return [...parts, { type: 'text', text: delta }];
+}
+
+/** Rewrites the parts of the message being written, opening one if the stream skipped `start`. */
+function withCurrent(
+ messages: AssistantMessage[],
+ now: () => string,
+ edit: (parts: AssistantMessagePart[]) => AssistantMessagePart[]
+): AssistantMessage[] {
+ const last = messages[messages.length - 1];
+ if (last?.role === 'assistant') return [...messages.slice(0, -1), { ...last, parts: edit(last.parts) }];
+ const opened = emptyAssistant(`stream-${messages.length}`, now());
+ return [...messages, { ...opened, parts: edit(opened.parts) }];
+}
+
+/** Sets the status of the message being written; a stream that never opened one has nothing to mark. */
+function withStatus(
+ messages: AssistantMessage[],
+ next: (m: AssistantMessage) => AssistantMessage['status']
+): AssistantMessage[] {
+ const last = messages[messages.length - 1];
+ if (last?.role !== 'assistant') return messages;
+ return [...messages.slice(0, -1), { ...last, status: next(last) }];
+}
+
+function findCall(messages: readonly AssistantMessage[], toolCallId: string): number {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i]?.parts.some((p) => p.type === 'tool-call' && p.toolCallId === toolCallId)) return i;
+ }
+ return -1;
+}
+
+function updateCall(
+ messages: AssistantMessage[],
+ toolCallId: string,
+ edit: (part: ToolCallPart) => ToolCallPart
+): AssistantMessage[] {
+ const at = findCall(messages, toolCallId);
+ if (at < 0) return messages;
+ return messages.map((m, i) =>
+ i === at
+ ? { ...m, parts: m.parts.map((p) => (p.type === 'tool-call' && p.toolCallId === toolCallId ? edit(p) : p)) }
+ : m
+ );
+}
+
+function upsertCall(
+ messages: AssistantMessage[],
+ now: () => string,
+ toolCallId: string,
+ toolName: string,
+ edit: (part: ToolCallPart) => ToolCallPart
+): AssistantMessage[] {
+ if (findCall(messages, toolCallId) >= 0) return updateCall(messages, toolCallId, (p) => edit({ ...p, toolName }));
+ return withCurrent(messages, now, (parts) => [...parts, edit({ type: 'tool-call', toolCallId, toolName, args: {} })]);
+}
+
+/** Puts the receipt of an applied change next to its call, so the card and its receipt stay together. */
+function placeApplied(messages: AssistantMessage[], now: () => string, data: AssistantApplied): AssistantMessage[] {
+ const receipt: AssistantMessagePart = { type: 'data-trek-applied', data };
+ const keep = (parts: AssistantMessagePart[]) => [
+ ...parts.filter((p) => !(p.type === 'data-trek-applied' && p.data.toolCallId === data.toolCallId)),
+ receipt,
+ ];
+ const at = findCall(messages, data.toolCallId);
+ if (at < 0) return withCurrent(messages, now, keep);
+ return messages.map((m, i) => (i === at ? { ...m, parts: keep(m.parts) } : m));
+}
diff --git a/client/src/store/assistantStore.map.test.ts b/client/src/store/assistantStore.map.test.ts
new file mode 100644
index 000000000..8f7abc5af
--- /dev/null
+++ b/client/src/store/assistantStore.map.test.ts
@@ -0,0 +1,312 @@
+/**
+ * FE-AI-STORE-MAP-001 to FE-AI-STORE-MAP-008
+ *
+ * The store's side of the assistant on the map: the live activity of a
+ * running step, mentions in a question, the "before" snapshot a decision
+ * takes, undoing a whole block, suggesting a found place for a day, and the
+ * hover, camera and window state the planner's map reads.
+ */
+import type { AssistantMessage, Trip, UiStreamChunk } from '@trek/shared';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { assistantRepo } from '../repo/assistantRepo';
+import type { Assignment } from '../types';
+import { chatOf, useAssistantStore } from './assistantStore';
+import { useTripStore } from './tripStore';
+
+const TRIP = 4;
+const ADD = 'c0ffee00-0000-4000-8000-00000000000a';
+const MOVE = 'c0ffee00-0000-4000-8000-00000000000b';
+
+async function* streamOf(chunks: UiStreamChunk[]): AsyncGenerator {
+ for (const chunk of chunks) yield chunk;
+}
+
+const chat = () => chatOf(useAssistantStore.getState(), TRIP);
+const store = () => useAssistantStore.getState();
+
+function visit(id: number, dayId: number): Assignment {
+ const place = { id: id * 10, name: `P${id}`, lat: 41, lng: 12 };
+ return { id, day_id: dayId, place_id: id * 10, order_index: 0, place } as Assignment;
+}
+
+/** A thread holding two changes for the map: a new place on day 3, and a stop moved from day 3 to day 5. */
+const HELD: AssistantMessage = {
+ id: 'msg-1',
+ role: 'assistant',
+ status: 'awaiting_approval',
+ createdAt: '2026-10-21T08:00:00.000Z',
+ parts: [
+ { type: 'tool-call', toolCallId: ADD, toolName: 'create_and_assign_place', args: {}, approval: { id: ADD } },
+ {
+ type: 'data-trek-change',
+ data: {
+ toolCallId: ADD,
+ verbKey: 'addPlaceToDay',
+ subject: 'Pantheon',
+ rows: [],
+ dayId: 3,
+ placeIds: [],
+ undoable: true,
+ effect: {
+ kind: 'add',
+ dayId: 3,
+ fromDayId: null,
+ placeId: null,
+ assignmentId: null,
+ place: { name: 'Pantheon', lat: 41.9, lng: 12.48, categoryId: null },
+ },
+ },
+ },
+ { type: 'tool-call', toolCallId: MOVE, toolName: 'move_assignment', args: {}, approval: { id: MOVE } },
+ {
+ type: 'data-trek-change',
+ data: {
+ toolCallId: MOVE,
+ verbKey: 'moveAssignment',
+ subject: 'Forum',
+ rows: [],
+ dayId: 5,
+ placeIds: [20],
+ undoable: true,
+ effect: { kind: 'move', dayId: 5, fromDayId: 3, placeId: 20, assignmentId: 2, place: null },
+ },
+ },
+ ],
+};
+
+function holdThread() {
+ useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat(), threadId: 7, messages: [HELD] } } });
+}
+
+beforeEach(() => {
+ useAssistantStore.setState({
+ status: null,
+ window: { open: true, minimized: false },
+ byTrip: {},
+ lastApplied: null,
+ mapLink: null,
+ camera: null,
+ preview: null,
+ windowRect: null,
+ });
+ useTripStore.setState({
+ trip: { id: TRIP } as Trip,
+ assignments: { 3: [visit(1, 3), visit(2, 3)], 5: [visit(3, 5)] },
+ });
+});
+afterEach(() => vi.restoreAllMocks());
+
+describe('assistantStore: the live line', () => {
+ it('FE-AI-STORE-MAP-001: a running step names its area until its result is in, and nothing is left once the turn ends', async () => {
+ const seen: (string | null)[] = [];
+ const unsubscribe = useAssistantStore.subscribe((s) => seen.push(chatOf(s, TRIP).live.activity?.label ?? null));
+ vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(
+ streamOf([
+ { type: 'start', messageId: 'm1' },
+ {
+ type: 'data-trek-activity',
+ data: {
+ toolCallId: 's1',
+ label: 'Trastevere',
+ area: { type: 'point', lat: 41.88, lng: 12.47, radiusM: 800 },
+ },
+ transient: true,
+ },
+ { type: 'tool-output-available', toolCallId: 's1', output: {} },
+ {
+ type: 'data-trek-activity',
+ data: { toolCallId: 's2', label: 'Monti', area: null },
+ transient: true,
+ },
+ { type: 'finish' },
+ ])
+ );
+ await store().send(TRIP, 'Lunch near Trastevere');
+ unsubscribe();
+ expect(seen).toContain('Trastevere');
+ expect(seen).toContain('Monti');
+ expect(seen.indexOf('Monti')).toBeGreaterThan(seen.lastIndexOf('Trastevere'));
+ expect(chat().live.activity).toBeNull();
+ // The stored message never keeps it.
+ expect(chat().messages[1]?.parts.some((p) => (p.type as string) === 'data-trek-activity')).toBe(false);
+ });
+});
+
+describe('assistantStore: mentions', () => {
+ it('FE-AI-STORE-MAP-002: a question carries the mentions still in its text, minus what the focus already names', async () => {
+ store().setFocus(TRIP, { dayId: 3 });
+ store().addMention(TRIP, { kind: 'place', id: 9, label: 'Louvre' });
+ store().addMention(TRIP, { kind: 'place', id: 9, label: 'Louvre' });
+ store().addMention(TRIP, { kind: 'reservation', id: 4, label: 'Ferry' });
+ store().addMention(TRIP, { kind: 'day', id: 3, label: 'Day 3' });
+ expect(chat().mentions).toHaveLength(3);
+ const send = vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(streamOf([{ type: 'finish' }]));
+
+ await store().send(TRIP, 'Is @Louvre open on @Day 3?');
+
+ expect(send.mock.calls[0]?.[1]).toMatchObject({ mentions: [{ kind: 'place', id: 9 }] });
+ const question = chat().messages[0];
+ expect(question?.parts[1]).toEqual({
+ type: 'data-trek-mentions',
+ data: { items: [{ kind: 'place', id: 9, label: 'Louvre' }] },
+ });
+ expect(chat().mentions).toEqual([]);
+ });
+});
+
+describe('assistantStore: before and after', () => {
+ it('FE-AI-STORE-MAP-003: a decision snapshots the days its applied changes touch, and notes the places they make', async () => {
+ holdThread();
+ vi.spyOn(assistantRepo, 'decide').mockResolvedValueOnce(
+ streamOf([
+ { type: 'start', messageId: 'msg-1' },
+ { type: 'tool-output-available', toolCallId: ADD, output: { id: 77 } },
+ { type: 'data-trek-applied', data: { toolCallId: ADD, entity: 'place', ids: [77], dayId: 3, undoUntil: null } },
+ { type: 'finish' },
+ ])
+ );
+
+ await store().decide(TRIP, [
+ { toolCallId: ADD, approved: true },
+ { toolCallId: MOVE, approved: false },
+ ]);
+
+ const compare = chat().compare;
+ expect(compare).toMatchObject({ blockKey: ADD, mode: 'after', newPlaceCalls: [ADD], addedPlaceIds: [77] });
+ expect(Object.keys(compare?.snapshot ?? {})).toEqual(['3']);
+ expect(compare?.snapshot[3]?.map((v) => v.id)).toEqual([1, 2]);
+
+ store().setCompare(TRIP, 'before');
+ expect(chat().compare?.mode).toBe('before');
+ expect(store().camera).toMatchObject({ tripId: TRIP, mode: 'fit', why: 'user' });
+ expect(store().camera?.points.length).toBeGreaterThan(0);
+
+ // A new question looks at the plan as it is now.
+ vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(streamOf([{ type: 'finish' }]));
+ await store().send(TRIP, 'Thanks');
+ expect(chat().compare?.mode).toBe('after');
+ });
+
+ it('FE-AI-STORE-MAP-004: no snapshot for another trip, and none left when the server refuses the decision', async () => {
+ holdThread();
+ useTripStore.setState({ trip: { id: 99 } as Trip });
+ vi.spyOn(assistantRepo, 'decide').mockResolvedValueOnce(streamOf([{ type: 'finish' }]));
+ await store().decide(TRIP, [{ toolCallId: ADD, approved: true }]);
+ expect(chat().compare).toBeNull();
+
+ holdThread();
+ useTripStore.setState({ trip: { id: TRIP } as Trip });
+ vi.spyOn(assistantRepo, 'decide').mockRejectedValueOnce({ response: { status: 500 } });
+ await store().decide(TRIP, [{ toolCallId: ADD, approved: true }]);
+ expect(chat().compare).toBeNull();
+ });
+
+ function holdCompare() {
+ holdThread();
+ useAssistantStore.setState({
+ byTrip: {
+ [TRIP]: {
+ ...chat(),
+ compare: { blockKey: 'a', mode: 'before', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] },
+ },
+ },
+ });
+ }
+
+ it('FE-AI-STORE-MAP-005: undoing a block goes last first and ends the comparison once all of it is undone', async () => {
+ holdCompare();
+ const undo = vi.spyOn(assistantRepo, 'undo').mockResolvedValue(undefined);
+
+ const result = await store().undoBlock(TRIP, ['a', 'b', 'c']);
+
+ expect(undo.mock.calls.map(([, id]) => id)).toEqual(['c', 'b', 'a']);
+ expect(result).toEqual({ done: 3, stale: 0, failed: 0 });
+ expect(chat().undone).toEqual({ a: true, b: true, c: true });
+ expect(chat().compare).toBeNull();
+ });
+
+ it('FE-AI-STORE-MAP-005b: a refusal stops the run and a stale change keeps the comparison', async () => {
+ holdCompare();
+ const undo = vi
+ .spyOn(assistantRepo, 'undo')
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce({ response: { status: 429 } });
+
+ const failed = await store().undoBlock(TRIP, ['a', 'b', 'c']);
+
+ expect(undo.mock.calls.map(([, id]) => id)).toEqual(['c', 'b']);
+ expect(failed).toEqual({ done: 1, stale: 0, failed: 1 });
+ expect(chat().undone).toEqual({ c: true });
+ expect(chat().compare).not.toBeNull();
+
+ undo.mockReset();
+ undo.mockRejectedValueOnce({ response: { status: 409 } }).mockResolvedValueOnce(undefined);
+ const stale = await store().undoBlock(TRIP, ['a', 'b']);
+
+ expect(stale).toEqual({ done: 1, stale: 1, failed: 0 });
+ expect(chat().compare).not.toBeNull();
+ });
+});
+
+describe('assistantStore: a found place for a day', () => {
+ it('FE-AI-STORE-MAP-006: the suggestion arrives as a message; a refusal shows its code', async () => {
+ holdThread();
+ const reply: AssistantMessage = { ...HELD, id: 'msg-2', parts: [] };
+ const add = vi.spyOn(assistantRepo, 'addFoundPlace').mockResolvedValueOnce({ message: reply });
+
+ await store().addFoundPlace(TRIP, 'call:0', 3);
+
+ expect(add).toHaveBeenCalledWith(TRIP, 7, expect.objectContaining({ ref: 'call:0', dayId: 3 }));
+ expect(chat().messages.map((m) => m.id)).toEqual(['msg-1', 'msg-2']);
+
+ // The server answers with the whole answer the card joined, under its id: it replaces, never doubles.
+ const joined: AssistantMessage = { ...reply, status: 'awaiting_approval' };
+ vi.spyOn(assistantRepo, 'addFoundPlace').mockResolvedValueOnce({ message: joined });
+ await store().addFoundPlace(TRIP, 'call:2', 3);
+ expect(chat().messages.map((m) => m.id)).toEqual(['msg-1', 'msg-2']);
+ expect(chat().messages[1]).toBe(joined);
+
+ vi.spyOn(assistantRepo, 'addFoundPlace').mockRejectedValueOnce({
+ response: { status: 409, data: { code: 'decide_first' } },
+ });
+ await store().addFoundPlace(TRIP, 'call:1', 3);
+ expect(chat().error).toBe('decide_first');
+ });
+});
+
+describe('assistantStore: the map link', () => {
+ it('FE-AI-STORE-MAP-007: a repeated hover changes nothing, and every camera request is a new one', () => {
+ store().setMapHover(TRIP, ADD, 'card');
+ const link = store().mapLink;
+ store().setMapHover(TRIP, ADD, 'card');
+ expect(store().mapLink).toBe(link);
+ store().setMapHover(TRIP, ADD, 'map');
+ expect(store().mapLink).toEqual({ tripId: TRIP, hoveredId: ADD, source: 'map' });
+
+ store().requestCamera(TRIP, [], 'fit', 'user');
+ expect(store().camera).toBeNull();
+ store().requestCamera(TRIP, [[41, 12]], 'pan', 'user');
+ store().requestCamera(TRIP, [[41, 12]], 'pan', 'user');
+ expect(store().camera).toMatchObject({ seq: 2, mode: 'pan', points: [[41, 12]] });
+ });
+
+ it('FE-AI-STORE-MAP-008: closing the window takes the hover and the preview pin along and goes back to "after"', () => {
+ useAssistantStore.setState({
+ byTrip: {
+ [TRIP]: {
+ ...chat(),
+ compare: { blockKey: 'a', mode: 'before', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] },
+ },
+ },
+ });
+ store().setMapHover(TRIP, ADD, 'card');
+ store().setPreviewPin({ tripId: TRIP, ref: 'call:0', lat: 41, lng: 12, name: 'Pantheon' });
+ store().setWindowRect({ x: 10, y: 20, width: 360, height: 480 });
+
+ store().close();
+
+ expect(store()).toMatchObject({ mapLink: null, preview: null, window: { open: false } });
+ expect(store().windowRect).toEqual({ x: 10, y: 20, width: 360, height: 480 });
+ expect(chat().compare?.mode).toBe('after');
+ });
+});
diff --git a/client/src/store/assistantStore.test.ts b/client/src/store/assistantStore.test.ts
new file mode 100644
index 000000000..0c97d5491
--- /dev/null
+++ b/client/src/store/assistantStore.test.ts
@@ -0,0 +1,323 @@
+/**
+ * FE-AI-STORE-001 to FE-AI-STORE-012
+ *
+ * The store runs a turn end to end against a faked repo: the request it
+ * sends, how the stream lands in the chat, and what each way of failing
+ * leaves behind (a refused question stays to be sent again, a stopped answer
+ * is marked, refused decisions go back to undecided). It never reaches Dexie,
+ * so these tests need no database.
+ */
+import type { AiStatusResponse, AssistantMessage, AssistantThreadResponse, UiStreamChunk } from '@trek/shared';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { AssistantRequestError } from '../api/assistant';
+import { aiConnectionsRepo } from '../repo/aiConnectionsRepo';
+import { assistantRepo } from '../repo/assistantRepo';
+import { OnlineOnlyError } from '../repo/onlineOnly';
+import { chatOf, useAssistantStore } from './assistantStore';
+import { useSettingsStore } from './settingsStore';
+
+const TRIP = 4;
+const WRITE_ID = 'c0ffee00-0000-4000-8000-000000000001';
+
+const STATUS: AiStatusResponse = {
+ available: true,
+ policy: 'instance',
+ canConnectOwn: false,
+ effective: { source: 'instance', kind: 'openai', model: 'gpt', host: 'api.openai.com', paidBy: 'instance' },
+ own: null,
+ instance: { kind: 'openai', model: 'gpt', host: 'api.openai.com' },
+ quota: { used: 2, limit: 50 },
+ privacyAcked: false,
+ retention: { mode: 'days', days: 90 },
+ openrouterOAuth: false,
+};
+
+const HELD_TURN: UiStreamChunk[] = [
+ { type: 'start', messageId: 'msg-1' },
+ { type: 'data-trek-thread', data: { threadId: 7, title: 'Friday' } },
+ { type: 'text-delta', id: 't', delta: 'Adding it.' },
+ { type: 'tool-input-available', toolCallId: WRITE_ID, toolName: 'create_todo', input: { name: 'Tickets' } },
+ { type: 'tool-approval-request', approvalId: WRITE_ID, toolCallId: WRITE_ID },
+ { type: 'data-trek-quota', data: { used: 3, limit: 50 } },
+ { type: 'finish', finishReason: 'tool-calls' },
+];
+
+async function* streamOf(chunks: UiStreamChunk[]): AsyncGenerator {
+ for (const chunk of chunks) yield chunk;
+}
+
+/** A stream that sends `first` and then waits until its request is aborted. */
+async function* hangingStream(first: UiStreamChunk[], signal: AbortSignal): AsyncGenerator {
+ yield* first;
+ await new Promise((_, reject) =>
+ signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
+ );
+}
+
+const chat = () => chatOf(useAssistantStore.getState(), TRIP);
+const store = () => useAssistantStore.getState();
+
+async function heldTurn() {
+ vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(streamOf(HELD_TURN));
+ await store().send(TRIP, 'Add tickets');
+}
+
+beforeEach(() => {
+ useAssistantStore.setState({
+ status: null,
+ window: { open: false, minimized: false },
+ byTrip: {},
+ lastApplied: null,
+ });
+ useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: 'de' } }));
+});
+afterEach(() => vi.restoreAllMocks());
+
+describe('assistantStore: a turn', () => {
+ it('FE-AI-STORE-001: sends the question with the context and folds the answer into the chat', async () => {
+ useAssistantStore.setState({ status: STATUS });
+ store().setFocus(TRIP, { placeIds: [9] });
+ store().setDraft(TRIP, 'Add tickets');
+ const send = vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(streamOf(HELD_TURN));
+
+ await store().send(TRIP, ' Add tickets ');
+
+ const [tripId, body] = send.mock.calls[0] ?? [];
+ expect(tripId).toBe(TRIP);
+ expect(body).toMatchObject({ locale: 'de', threadId: null, text: 'Add tickets', focus: { placeIds: [9] } });
+ expect(typeof body?.timeZone).toBe('string');
+ const c = chat();
+ expect(c).toMatchObject({ threadId: 7, isRunning: false, error: null, draft: '', abort: null });
+ expect(c.messages.map((m) => [m.role, m.status])).toEqual([
+ ['user', 'complete'],
+ ['assistant', 'awaiting_approval'],
+ ]);
+ expect(store().status?.quota).toEqual({ used: 3, limit: 50 });
+ });
+
+ it('FE-AI-STORE-002: a refused question stays to be sent again, and "continue here" stops the other turn first', async () => {
+ vi.spyOn(assistantRepo, 'send').mockRejectedValueOnce(new AssistantRequestError('busy', 409));
+ await store().send(TRIP, 'Hello');
+ expect(chat()).toMatchObject({ error: 'busy', unsent: 'Hello', isRunning: false });
+ expect(chat().messages).toHaveLength(1);
+
+ const stop = vi.spyOn(assistantRepo, 'stop').mockResolvedValueOnce(undefined);
+ const resend = vi
+ .spyOn(assistantRepo, 'send')
+ .mockResolvedValueOnce(streamOf([{ type: 'start', messageId: 'm2' }, { type: 'finish' }]));
+ await store().continueHere(TRIP);
+
+ expect(stop).toHaveBeenCalledWith(TRIP);
+ expect(resend.mock.calls[0]?.[1]).toMatchObject({ text: 'Hello' });
+ expect(chat().messages.map((m) => m.role)).toEqual(['user', 'assistant']);
+ expect(chat()).toMatchObject({ error: null, unsent: null });
+ });
+
+ it('FE-AI-STORE-003: stopping marks the answer as stopped, without an error', async () => {
+ vi.spyOn(assistantRepo, 'send').mockImplementationOnce(async (_trip, _body, signal) =>
+ hangingStream(
+ [
+ { type: 'start', messageId: 'm1' },
+ { type: 'text-delta', id: 't', delta: 'Let me' },
+ ],
+ signal
+ )
+ );
+ const running = store().send(TRIP, 'Plan it');
+ await vi.waitFor(() => expect(chat().isRunning).toBe(true));
+ await vi.waitFor(() => expect(chat().abort).not.toBeNull());
+
+ store().cancel(TRIP);
+ await running;
+
+ expect(chat()).toMatchObject({ isRunning: false, error: null });
+ expect(chat().messages[1]).toMatchObject({ status: 'aborted', parts: [{ type: 'text', text: 'Let me' }] });
+ });
+
+ it('FE-AI-STORE-004: a dropped connection is an error, and retry asks the same question again', async () => {
+ async function* broken(): AsyncGenerator {
+ yield { type: 'start', messageId: 'm1' };
+ throw new TypeError('network error');
+ }
+ vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(broken());
+ await store().send(TRIP, 'Plan it');
+ expect(chat()).toMatchObject({ error: 'generic', unsent: null });
+ expect(chat().messages[1]?.status).toBe('aborted');
+
+ const again = vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(streamOf([{ type: 'finish' }]));
+ await store().retry(TRIP);
+ expect(again.mock.calls[0]?.[1]).toMatchObject({ text: 'Plan it' });
+ });
+
+ it('FE-AI-STORE-005: an error chunk becomes the chat error; offline is named as such', async () => {
+ vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(
+ streamOf([{ type: 'start', messageId: 'm1' }, { type: 'error', errorText: 'max_steps' }, { type: 'finish' }])
+ );
+ await store().send(TRIP, 'Plan everything');
+ expect(chat().error).toBe('max_steps');
+
+ vi.spyOn(assistantRepo, 'send').mockRejectedValueOnce(new OnlineOnlyError());
+ await store().send(TRIP, 'Again');
+ expect(chat().error).toBe('offline');
+ });
+
+ it('FE-AI-STORE-006: a new chat while a turn runs leaves the old turn without a say', async () => {
+ let release: () => void = () => {};
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ async function* slow(): AsyncGenerator {
+ yield { type: 'start', messageId: 'old' };
+ await gate;
+ yield { type: 'text-delta', id: 't', delta: 'late' };
+ }
+ vi.spyOn(assistantRepo, 'send').mockResolvedValueOnce(slow());
+ const running = store().send(TRIP, 'Old question');
+ await vi.waitFor(() => expect(chat().isRunning).toBe(true));
+
+ store().newThread(TRIP);
+ release();
+ await running;
+
+ expect(chat()).toMatchObject({ threadId: null, messages: [], isRunning: false, error: null });
+ });
+});
+
+describe('assistantStore: decisions and undo', () => {
+ it('FE-AI-STORE-007: decisions go to the thread, and the held call is applied', async () => {
+ await heldTurn();
+ const decide = vi.spyOn(assistantRepo, 'decide').mockResolvedValueOnce(
+ streamOf([
+ { type: 'start', messageId: 'msg-1' },
+ { type: 'tool-output-available', toolCallId: WRITE_ID, output: { id: 3 } },
+ {
+ type: 'data-trek-applied',
+ data: { toolCallId: WRITE_ID, entity: 'todo', ids: [3], dayId: null, undoUntil: '2026-10-21T08:15:00.000Z' },
+ },
+ { type: 'finish', finishReason: 'stop' },
+ ])
+ );
+
+ await store().decide(TRIP, [{ toolCallId: WRITE_ID, approved: true }]);
+
+ expect(decide).toHaveBeenCalledWith(
+ TRIP,
+ 7,
+ expect.objectContaining({ locale: 'de', decisions: [{ toolCallId: WRITE_ID, approved: true }] }),
+ expect.any(AbortSignal)
+ );
+ expect(chat().messages[1]?.status).toBe('complete');
+ expect(store().lastApplied).toMatchObject({ tripId: TRIP, applied: { entity: 'todo', ids: [3] } });
+ });
+
+ it('FE-AI-STORE-008: refused decisions go back to undecided, with the reason', async () => {
+ await heldTurn();
+ const held = chat().messages;
+ vi.spyOn(assistantRepo, 'decide').mockRejectedValueOnce(new AssistantRequestError('rate_limited', 429));
+
+ await store().decide(TRIP, [{ toolCallId: WRITE_ID, approved: true }]);
+
+ expect(chat().messages).toEqual(held);
+ expect(chat()).toMatchObject({ error: 'rate_limited', unsent: null, isRunning: false });
+ });
+
+ it('FE-AI-STORE-009: undo closes the window when done or stale, and keeps it when it failed', async () => {
+ const receipt: AssistantMessage = {
+ id: 'a',
+ role: 'assistant',
+ status: 'complete',
+ createdAt: '2026-10-21T08:00:00.000Z',
+ parts: [
+ {
+ type: 'data-trek-applied',
+ data: { toolCallId: 'c1', entity: 'todo', ids: [3], dayId: null, undoUntil: '2026-10-21T08:15:00.000Z' },
+ },
+ ],
+ };
+ const undoUntil = () => {
+ const part = chat().messages[0]?.parts[0];
+ return part?.type === 'data-trek-applied' ? part.data.undoUntil : 'missing';
+ };
+ const reset = () => useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat(), messages: [receipt] } } });
+
+ reset();
+ vi.spyOn(assistantRepo, 'undo').mockRejectedValueOnce({ response: { status: 500 } });
+ await expect(store().undo(TRIP, 'c1')).resolves.toBe('failed');
+ expect(undoUntil()).toBe('2026-10-21T08:15:00.000Z');
+
+ vi.spyOn(assistantRepo, 'undo').mockRejectedValueOnce({ response: { status: 409 } });
+ await expect(store().undo(TRIP, 'c1')).resolves.toBe('stale');
+ expect(undoUntil()).toBeNull();
+
+ reset();
+ vi.spyOn(assistantRepo, 'undo').mockResolvedValueOnce(undefined);
+ await expect(store().undo(TRIP, 'c1')).resolves.toBe('undone');
+ expect(undoUntil()).toBeNull();
+ });
+});
+
+describe('assistantStore: threads, window and status', () => {
+ const THREAD: AssistantThreadResponse = {
+ thread: { id: 7, title: 'Friday', lastMessageAt: '2026-10-21T08:00:00.000Z' },
+ messages: [
+ {
+ id: '1',
+ role: 'user',
+ parts: [{ type: 'text', text: 'Hi' }],
+ status: 'complete',
+ createdAt: '2026-10-21T08:00:00.000Z',
+ },
+ ],
+ };
+
+ it('FE-AI-STORE-010: resume opens the latest thread; a thread deleted elsewhere is forgotten', async () => {
+ vi.spyOn(assistantRepo, 'threads').mockResolvedValueOnce([THREAD.thread, { ...THREAD.thread, id: 6 }]);
+ vi.spyOn(assistantRepo, 'thread').mockResolvedValueOnce(THREAD);
+ await store().resume(TRIP);
+ expect(chat()).toMatchObject({ threadId: 7, messages: THREAD.messages, loading: false });
+
+ vi.spyOn(assistantRepo, 'thread').mockRejectedValueOnce({ response: { status: 404 } });
+ await store().openThread(TRIP, 6);
+ expect(chat()).toMatchObject({ threadId: null, messages: [], loading: false, threads: [THREAD.thread] });
+
+ vi.spyOn(assistantRepo, 'deleteThread').mockResolvedValueOnce(undefined);
+ useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat(), threadId: 7, messages: THREAD.messages } } });
+ await store().deleteThread(TRIP, 7);
+ expect(chat()).toMatchObject({ threadId: null, messages: [], threads: [] });
+ });
+
+ it('FE-AI-STORE-011: the window opens, minimizes, toggles and opens on a place', () => {
+ store().toggle();
+ expect(store().window).toEqual({ open: true, minimized: false });
+ store().setMinimized(true);
+ store().toggle();
+ expect(store().window).toEqual({ open: true, minimized: false });
+ store().toggle();
+ expect(store().window.open).toBe(false);
+
+ store().openWith(TRIP, { placeIds: [9] });
+ expect(store().window).toEqual({ open: true, minimized: false });
+ expect(chat().focus).toEqual({ placeIds: [9] });
+ store().close();
+ expect(store().window.open).toBe(false);
+ });
+
+ it('FE-AI-STORE-012: status fails closed, offline keeps the last answer, and the privacy notice is acknowledged', async () => {
+ vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValueOnce(STATUS);
+ await store().loadStatus();
+ expect(store().status).toEqual(STATUS);
+
+ vi.spyOn(aiConnectionsRepo, 'status').mockRejectedValueOnce(new OnlineOnlyError());
+ await store().loadStatus();
+ expect(store().status).toEqual(STATUS);
+
+ const ack = vi.spyOn(aiConnectionsRepo, 'ackPrivacy').mockResolvedValueOnce(undefined);
+ await store().ackPrivacy();
+ expect(ack).toHaveBeenCalledWith({ kind: 'openai', host: 'api.openai.com' });
+ expect(store().status?.privacyAcked).toBe(true);
+
+ vi.spyOn(aiConnectionsRepo, 'status').mockRejectedValueOnce({ response: { status: 500 } });
+ await store().loadStatus();
+ expect(store().status).toBeNull();
+ });
+});
diff --git a/client/src/store/assistantStore.ts b/client/src/store/assistantStore.ts
new file mode 100644
index 000000000..afce38900
--- /dev/null
+++ b/client/src/store/assistantStore.ts
@@ -0,0 +1,724 @@
+import {
+ ASSISTANT_ERROR_CODES,
+ type AiStatusResponse,
+ type AssistantActivity,
+ type AssistantApplied,
+ type AssistantDecision,
+ type AssistantErrorCode,
+ type AssistantFocus,
+ type AssistantMention,
+ type AssistantMessage,
+ type AssistantMessagePart,
+ type AssistantThreadSummary,
+ type UiStreamChunk,
+} from '@trek/shared';
+import { create, type StoreApi } from 'zustand';
+import { AssistantRequestError } from '../api/assistant';
+import { aiConnectionsRepo } from '../repo/aiConnectionsRepo';
+import { assistantRepo } from '../repo/assistantRepo';
+import { OnlineOnlyError } from '../repo/onlineOnly';
+import type { Assignment } from '../types';
+import { randomId } from '../utils/randomId';
+import {
+ blockDays,
+ blockKeyOf,
+ changeEffects,
+ dayPoints,
+ snapshotDays,
+ type LatLng,
+ type WindowRect,
+} from './assistantMapModel';
+import { applyChunk, closeUndo, markDecisions } from './assistantMessages';
+import { useSettingsStore } from './settingsStore';
+import { useTripStore } from './tripStore';
+
+/**
+ * The trip assistant: whether it can run, whether its window is open, and one
+ * conversation per trip.
+ *
+ * Online-only like the repos it calls; nothing here reaches Dexie. Messages
+ * are kept in the server's stored shape (`AssistantMessage`), and the stream
+ * is folded into them by `applyChunk`, so a streamed answer and a reloaded
+ * one are the same data. The view converts them for @assistant-ui; this file
+ * does not import it.
+ *
+ * Streaming writes reach the store at most once per animation frame: a fast
+ * model sends hundreds of deltas a second, and each set() re-renders the
+ * thread.
+ */
+
+interface AssistantTripChat {
+ /** The open thread; null for a new one the first message creates. */
+ threadId: number | null;
+ /** The user's latest threads in this trip; null until loaded. */
+ threads: AssistantThreadSummary[] | null;
+ messages: AssistantMessage[];
+ isRunning: boolean;
+ /** A thread is being loaded. */
+ loading: boolean;
+ error: AssistantErrorCode | null;
+ /** What the next message is about: a place the user asked from, or the selected day. */
+ focus: AssistantFocus | null;
+ /** What is typed but not sent, kept while the phone screen hops to the map. */
+ draft: string;
+ /** The text of a message the server refused before answering, sent again by retry(). */
+ unsent: string | null;
+ /** Ends the running stream. */
+ abort: AbortController | null;
+ /** What the running step is doing (the place it searches near, the area); streamed only, never stored. */
+ live: { activity: AssistantActivity | null };
+ /** The applied changes taken back in this session, by call. */
+ undone: Record;
+ /** "Before" and "after" of the last block of changes applied here; null when there is none to compare. */
+ compare: AssistantCompare | null;
+ /** The days, places and bookings named with @ in the draft. */
+ mentions: AssistantMentionItem[];
+}
+
+/** A mention as the composer picked it, with the label it wrote into the text. */
+export type AssistantMentionItem = AssistantMention & { label: string };
+
+/**
+ * The plan of the days one applied block of changes touched, as it was just
+ * before, so the map can show either side. Lives only in this session.
+ */
+interface AssistantCompare {
+ /** The block it belongs to, `blockKeyOf` its calls. */
+ blockKey: string;
+ mode: 'before' | 'after';
+ /** The affected days' stops before the block ran, by day. */
+ snapshot: Record;
+ /** The calls of the block that make a new place. */
+ newPlaceCalls: string[];
+ /** The places those calls made, hidden while "before" is on. */
+ addedPlaceIds: number[];
+}
+
+/** Where a hover on a change came from: its row in the card, its pin on the map, a found place in the strip. */
+type AssistantMapSource = 'card' | 'map' | 'strip';
+
+type UndoOutcome = 'undone' | 'stale' | 'failed';
+
+/** How taking back a whole block went, counted per change. */
+interface UndoBlockResult {
+ done: number;
+ stale: number;
+ failed: number;
+}
+
+interface AssistantState {
+ /** What the assistant runs on for this user; null until loaded, and the assistant stays hidden until then. */
+ status: AiStatusResponse | null;
+ window: { open: boolean; minimized: boolean };
+ byTrip: Record;
+ /** The change applied last, for the planner to jump to it. */
+ lastApplied: { tripId: number; applied: AssistantApplied } | null;
+ /**
+ * The change under the pointer, shared by its card row and its pin on the
+ * map, and the side the pointer is on. One planner is on screen at a time,
+ * so this is global and names its trip.
+ */
+ mapLink: { tripId: number; hoveredId: string | null; source: AssistantMapSource } | null;
+ /** The latest request to move the map; `seq` tells a new one from the same points asked again. */
+ camera: { tripId: number; seq: number; points: LatLng[]; mode: 'fit' | 'pan'; why: 'user' | 'auto' } | null;
+ /** A found place the pointer rests on, drawn on the map until it leaves. */
+ preview: { tripId: number; ref: string; lat: number; lng: number; name: string } | null;
+ /** Where the floating window sits after its last move or resize, for the map to frame around it. */
+ windowRect: WindowRect | null;
+
+ loadStatus: () => Promise;
+ ackPrivacy: () => Promise;
+
+ open: () => void;
+ close: () => void;
+ toggle: () => void;
+ setMinimized: (minimized: boolean) => void;
+ /** Opens the window on a trip with a context chip ("Ask" on a place). */
+ openWith: (tripId: number, focus: AssistantFocus | null) => void;
+ setFocus: (tripId: number, focus: AssistantFocus | null) => void;
+ setDraft: (tripId: number, draft: string) => void;
+
+ /** Loads the thread list and, when nothing is open yet, the latest thread. */
+ resume: (tripId: number) => Promise;
+ loadThreads: (tripId: number) => Promise;
+ openThread: (tripId: number, threadId: number) => Promise;
+ newThread: (tripId: number) => void;
+ deleteThread: (tripId: number, threadId: number) => Promise;
+
+ send: (tripId: number, text: string) => Promise;
+ /** Hands a booking file to the chat, which proposes what it finds in it. */
+ attach: (tripId: number, file: File) => Promise;
+ decide: (tripId: number, decisions: AssistantDecision[]) => Promise;
+ undo: (tripId: number, toolCallId: string) => Promise;
+ cancel: (tripId: number) => void;
+ /** Sends the last question again: the refused message, or the one whose answer broke off. */
+ retry: (tripId: number) => Promise;
+ /** Stops the turn running in another window, then sends here. */
+ continueHere: (tripId: number) => Promise;
+ dismissError: (tripId: number) => void;
+
+ /** Takes back the applied changes of one block, the last one first. */
+ undoBlock: (tripId: number, toolCallIds: readonly string[]) => Promise;
+ /** Shows the plan before or after the last applied block, on the map. */
+ setCompare: (tripId: number, mode: 'before' | 'after') => void;
+ /** Suggests a place a search found for a day; the server holds it as a change to decide like any other. */
+ addFoundPlace: (tripId: number, ref: string, dayId: number) => Promise;
+ addMention: (tripId: number, mention: AssistantMentionItem) => void;
+
+ setMapHover: (tripId: number, hoveredId: string | null, source: AssistantMapSource) => void;
+ requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') => void;
+ setPreviewPin: (preview: AssistantState['preview']) => void;
+ setWindowRect: (rect: WindowRect | null) => void;
+}
+
+const NO_LIVE: AssistantTripChat['live'] = { activity: null };
+
+const EMPTY_CHAT: AssistantTripChat = {
+ threadId: null,
+ threads: null,
+ messages: [],
+ isRunning: false,
+ loading: false,
+ error: null,
+ focus: null,
+ draft: '',
+ unsent: null,
+ abort: null,
+ live: NO_LIVE,
+ undone: {},
+ compare: null,
+ mentions: [],
+};
+
+/** A trip's chat as the store holds it, or the empty one; stable, so a selector can return it. */
+export function chatOf(state: Pick, tripId: number): AssistantTripChat {
+ return state.byTrip[tripId] ?? EMPTY_CHAT;
+}
+
+function httpStatus(err: unknown): number | undefined {
+ return (err as { response?: { status?: number } } | null)?.response?.status;
+}
+
+const KNOWN_CODES: ReadonlySet = new Set(ASSISTANT_ERROR_CODES);
+
+/** The code a failure shows as: the one the server named in its body, else what the status says. */
+function errorCodeOf(err: unknown): AssistantErrorCode {
+ if (err instanceof AssistantRequestError) return err.code;
+ if (err instanceof OnlineOnlyError) return 'offline';
+ const named = (err as { response?: { data?: { code?: unknown } } } | null)?.response?.data?.code;
+ if (typeof named === 'string' && KNOWN_CODES.has(named)) return named as AssistantErrorCode;
+ const status = httpStatus(err);
+ if (status === 429) return 'too_many_requests';
+ if (status === 409) return 'busy';
+ return 'generic';
+}
+
+/** The language to answer in, and the zone "today" is read in. */
+function clientContext() {
+ return {
+ locale: useSettingsStore.getState().settings.language || 'en',
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
+ };
+}
+
+/** Runs `fn` once in the next frame, however often it is asked for until then. */
+function frameBatcher(fn: () => void) {
+ const hasFrames = typeof requestAnimationFrame === 'function';
+ let handle: ReturnType | number | null = null;
+ const fire = () => {
+ handle = null;
+ fn();
+ };
+ return {
+ schedule() {
+ if (handle === null) handle = hasFrames ? requestAnimationFrame(fire) : setTimeout(fire, 16);
+ },
+ cancel() {
+ if (handle === null) return;
+ if (hasFrames) cancelAnimationFrame(handle as number);
+ else clearTimeout(handle as ReturnType);
+ handle = null;
+ },
+ };
+}
+
+interface Ctx {
+ set: StoreApi['setState'];
+ get: StoreApi['getState'];
+}
+
+const chat = (ctx: Ctx, tripId: number) => chatOf(ctx.get(), tripId);
+
+function patch(ctx: Ctx, tripId: number, next: Partial) {
+ ctx.set((s) => ({ byTrip: { ...s.byTrip, [tripId]: { ...chatOf(s, tripId), ...next } } }));
+}
+
+/** What the store takes from a chunk beside the messages. */
+function onChunk(ctx: Ctx, tripId: number, chunk: UiStreamChunk) {
+ if (chunk.type === 'data-trek-thread') {
+ const { threadId, title } = chunk.data;
+ const threads = chat(ctx, tripId).threads;
+ const summary: AssistantThreadSummary = { id: threadId, title, lastMessageAt: new Date().toISOString() };
+ patch(ctx, tripId, { threadId, threads: threads ? [summary, ...threads.filter((t) => t.id !== threadId)] : null });
+ } else if (chunk.type === 'data-trek-quota') {
+ const status = ctx.get().status;
+ if (status) ctx.set({ status: { ...status, quota: chunk.data } });
+ } else if (chunk.type === 'data-trek-applied') {
+ ctx.set({ lastApplied: { tripId, applied: chunk.data } });
+ noteAddedPlace(ctx, tripId, chunk.data);
+ } else if (chunk.type === 'data-trek-activity') {
+ patch(ctx, tripId, { live: { activity: chunk.data } });
+ } else if (chunk.type === 'tool-output-available' || chunk.type === 'tool-output-error') {
+ // The step the line spoke of is done; the next one says what it does itself.
+ if (chat(ctx, tripId).live.activity) patch(ctx, tripId, { live: NO_LIVE });
+ }
+}
+
+/** A place the compared block made, so "before" can leave it off the map. */
+function noteAddedPlace(ctx: Ctx, tripId: number, applied: AssistantApplied) {
+ const compare = chat(ctx, tripId).compare;
+ const placeId = applied.ids[0];
+ if (!compare || placeId === undefined || !compare.newPlaceCalls.includes(applied.toolCallId)) return;
+ patch(ctx, tripId, { compare: { ...compare, addedPlaceIds: [...compare.addedPlaceIds, placeId] } });
+}
+
+interface Turn {
+ /** The messages while the turn runs: with the new question, or with the decisions marked. */
+ messages: AssistantMessage[];
+ /** What they go back to when the server refuses before answering. */
+ before: AssistantMessage[];
+ /** The question's text, kept for retry() when the server refuses it. */
+ unsent: string | null;
+ open: (signal: AbortSignal) => Promise>;
+}
+
+/** Opens a stream and folds it into the trip's chat until it ends. */
+async function runTurn(ctx: Ctx, tripId: number, turn: Turn): Promise {
+ const abort = new AbortController();
+ patch(ctx, tripId, { messages: turn.messages, isRunning: true, error: null, unsent: null, abort });
+ // "New chat" replaces this turn's controller; from then on the turn writes nothing.
+ const live = () => chat(ctx, tripId).abort === abort;
+ let current = turn.messages;
+ let error: AssistantErrorCode | null = null;
+ const commit = frameBatcher(() => {
+ if (live()) patch(ctx, tripId, { messages: current });
+ });
+ try {
+ let stream: AsyncGenerator;
+ try {
+ stream = await turn.open(abort.signal);
+ } catch (err) {
+ // A question stays on screen to be sent again; held changes go back to undecided.
+ current = turn.unsent === null ? turn.before : turn.messages;
+ if (!abort.signal.aborted) {
+ error = errorCodeOf(err);
+ if (live()) patch(ctx, tripId, { unsent: turn.unsent });
+ }
+ return;
+ }
+ for await (const chunk of stream) {
+ current = applyChunk(current, chunk);
+ if (chunk.type === 'error') error = chunk.errorText;
+ if (live()) onChunk(ctx, tripId, chunk);
+ commit.schedule();
+ }
+ } catch (err) {
+ // The stream broke off: stopped here, or the connection dropped.
+ current = applyChunk(current, { type: 'abort' });
+ if (!abort.signal.aborted) error = errorCodeOf(err);
+ } finally {
+ commit.cancel();
+ if (live()) patch(ctx, tripId, { messages: current, isRunning: false, error, abort: null, live: NO_LIVE });
+ }
+}
+
+async function sendMessage(ctx: Ctx, tripId: number, text: string): Promise {
+ const c = chat(ctx, tripId);
+ const trimmed = text.trim();
+ if (c.isRunning || !trimmed) return;
+ const mentions = sentMentions(c.mentions, trimmed, c.focus);
+ const parts: AssistantMessagePart[] = [{ type: 'text', text: trimmed }];
+ if (mentions.length > 0) parts.push({ type: 'data-trek-mentions', data: { items: mentions } });
+ const question: AssistantMessage = {
+ id: `local-${randomId()}`,
+ role: 'user',
+ parts,
+ status: 'complete',
+ createdAt: new Date().toISOString(),
+ };
+ const body = {
+ ...clientContext(),
+ threadId: c.threadId,
+ text: trimmed,
+ ...(c.focus ? { focus: c.focus } : {}),
+ ...(mentions.length > 0 ? { mentions: mentions.map(({ kind, id }) => ({ kind, id })) } : {}),
+ };
+ // A new question looks at the plan as it is now.
+ patch(ctx, tripId, { draft: '', mentions: [], compare: c.compare && { ...c.compare, mode: 'after' } });
+ await runTurn(ctx, tripId, {
+ messages: [...c.messages, question],
+ before: c.messages,
+ unsent: trimmed,
+ open: (signal) => assistantRepo.send(tripId, body, signal),
+ });
+}
+
+async function attachFile(ctx: Ctx, tripId: number, file: File): Promise {
+ const c = chat(ctx, tripId);
+ if (c.isRunning) return;
+ const question: AssistantMessage = {
+ id: `local-${randomId()}`,
+ role: 'user',
+ parts: [{ type: 'attachment', name: file.name, mime: file.type || 'application/octet-stream', size: file.size }],
+ status: 'complete',
+ createdAt: new Date().toISOString(),
+ };
+ const fields = { ...clientContext(), threadId: c.threadId };
+ await runTurn(ctx, tripId, {
+ messages: [...c.messages, question],
+ before: c.messages,
+ unsent: null,
+ open: (signal) => assistantRepo.attach(tripId, file, fields, signal),
+ });
+}
+
+/** Mentions the server takes at most per message. */
+const MAX_MENTIONS = 10;
+const MAX_MENTION_LABEL = 120;
+
+/**
+ * The mentions a question carries: those whose `@label` is still in its text
+ * (the text is the truth; deleting the words drops the mention), without the
+ * day or places the focus already names.
+ */
+function sentMentions(
+ mentions: readonly AssistantMentionItem[],
+ text: string,
+ focus: AssistantFocus | null
+): AssistantMentionItem[] {
+ return mentions
+ .filter((m) => text.includes(`@${m.label}`))
+ .filter((m) => !(m.kind === 'day' && focus?.dayId === m.id))
+ .filter((m) => !(m.kind === 'place' && focus?.placeIds?.includes(m.id)))
+ .slice(0, MAX_MENTIONS)
+ .map((m) => ({ kind: m.kind, id: m.id, label: m.label.slice(0, MAX_MENTION_LABEL) }));
+}
+
+/** The text of the latest question in the chat. */
+function lastQuestion(messages: readonly AssistantMessage[]): string | null {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const m = messages[i];
+ if (m?.role !== 'user') continue;
+ const part = m.parts.find((p) => p.type === 'text');
+ return part?.type === 'text' ? part.text : null;
+ }
+ return null;
+}
+
+/** Every trip's comparison back on "after". */
+function backToAfter(byTrip: Record): Record {
+ const next: Record = {};
+ for (const [id, c] of Object.entries(byTrip)) {
+ next[Number(id)] = c.compare ? { ...c, compare: { ...c.compare, mode: 'after' } } : c;
+ }
+ return next;
+}
+
+function shellActions(ctx: Ctx) {
+ return {
+ loadStatus: async () => {
+ try {
+ ctx.set({ status: await aiConnectionsRepo.status() });
+ } catch (err) {
+ // Offline keeps the last answer; any other failure hides the assistant (fail closed).
+ if (!(err instanceof OnlineOnlyError)) ctx.set({ status: null });
+ }
+ },
+ ackPrivacy: async () => {
+ const effective = ctx.get().status?.effective;
+ if (!effective) return;
+ await aiConnectionsRepo.ackPrivacy({ kind: effective.kind, host: effective.host });
+ const status = ctx.get().status;
+ if (status) ctx.set({ status: { ...status, privacyAcked: true } });
+ },
+ open: () => ctx.set({ window: { open: true, minimized: false } }),
+ // Closing leaves the map as the plan is now: "after", nothing hovered, no preview pin.
+ close: () =>
+ ctx.set((s) => ({
+ window: { open: false, minimized: false },
+ mapLink: null,
+ preview: null,
+ byTrip: backToAfter(s.byTrip),
+ })),
+ // Closed or minimized opens it; open closes it.
+ toggle: () => ctx.set((s) => ({ window: { open: !s.window.open || s.window.minimized, minimized: false } })),
+ setMinimized: (minimized: boolean) => ctx.set((s) => ({ window: { ...s.window, minimized } })),
+ openWith: (tripId: number, focus: AssistantFocus | null) => {
+ patch(ctx, tripId, { focus });
+ ctx.set({ window: { open: true, minimized: false } });
+ },
+ setFocus: (tripId: number, focus: AssistantFocus | null) => patch(ctx, tripId, { focus }),
+ setDraft: (tripId: number, draft: string) => patch(ctx, tripId, { draft }),
+ };
+}
+
+function threadActions(ctx: Ctx) {
+ const loadThreads = async (tripId: number) => {
+ try {
+ patch(ctx, tripId, { threads: await assistantRepo.threads(tripId) });
+ } catch (err) {
+ patch(ctx, tripId, { error: errorCodeOf(err) });
+ }
+ };
+
+ const openThread = async (tripId: number, threadId: number) => {
+ if (chat(ctx, tripId).isRunning) return;
+ patch(ctx, tripId, { threadId, loading: true, error: null, unsent: null });
+ try {
+ const { messages } = await assistantRepo.thread(tripId, threadId);
+ // Another thread may have been opened while this one loaded.
+ if (chat(ctx, tripId).threadId === threadId) patch(ctx, tripId, { messages, loading: false });
+ } catch (err) {
+ if (httpStatus(err) !== 404) {
+ patch(ctx, tripId, { loading: false, error: errorCodeOf(err) });
+ return;
+ }
+ // Deleted elsewhere: start fresh and forget it.
+ const threads = chat(ctx, tripId).threads;
+ patch(ctx, tripId, {
+ threadId: null,
+ messages: [],
+ loading: false,
+ threads: threads ? threads.filter((t) => t.id !== threadId) : null,
+ });
+ }
+ };
+
+ const newThread = (tripId: number) => {
+ chat(ctx, tripId).abort?.abort();
+ // The block a comparison belonged to leaves the screen with its thread.
+ patch(ctx, tripId, {
+ threadId: null,
+ messages: [],
+ isRunning: false,
+ error: null,
+ unsent: null,
+ abort: null,
+ live: NO_LIVE,
+ compare: null,
+ });
+ };
+
+ return {
+ loadThreads,
+ openThread,
+ newThread,
+ resume: async (tripId: number) => {
+ await loadThreads(tripId);
+ const c = chat(ctx, tripId);
+ const latest = c.threads?.[0];
+ if (latest && c.threadId === null && c.messages.length === 0 && !c.isRunning) await openThread(tripId, latest.id);
+ },
+ deleteThread: async (tripId: number, threadId: number) => {
+ await assistantRepo.deleteThread(tripId, threadId);
+ const c = chat(ctx, tripId);
+ patch(ctx, tripId, { threads: c.threads ? c.threads.filter((t) => t.id !== threadId) : null });
+ if (c.threadId === threadId) newThread(tripId);
+ },
+ };
+}
+
+async function undoOne(ctx: Ctx, tripId: number, toolCallId: string): Promise {
+ let outcome: UndoOutcome;
+ try {
+ await assistantRepo.undo(tripId, toolCallId);
+ outcome = 'undone';
+ } catch (err) {
+ const status = httpStatus(err);
+ // Changed since, or the window closed: the button goes, the change stays.
+ outcome = status === 409 || status === 410 ? 'stale' : 'failed';
+ }
+ if (outcome === 'failed') return outcome;
+ const c = chat(ctx, tripId);
+ patch(ctx, tripId, {
+ messages: closeUndo(c.messages, toolCallId),
+ ...(outcome === 'undone' ? { undone: { ...c.undone, [toolCallId]: true as const } } : {}),
+ });
+ return outcome;
+}
+
+/**
+ * The comparison a decision sets up: the plan of every day the applied
+ * changes touch, taken from the trip store just before they run. Only while
+ * the planner holds this very trip, since the store holds one trip at a time.
+ */
+function compareFor(
+ tripId: number,
+ messages: readonly AssistantMessage[],
+ decisions: readonly AssistantDecision[]
+): AssistantCompare | null {
+ const trip = useTripStore.getState();
+ if (trip.trip?.id !== tripId) return null;
+ const effects = changeEffects(messages);
+ const applied = decisions.filter((d) => d.approved).map((d) => d.toolCallId);
+ const touched = applied.flatMap((id) => {
+ const effect = effects.get(id);
+ return effect ? [effect] : [];
+ });
+ if (touched.length === 0) return null;
+ return {
+ blockKey: blockKeyOf(decisions.map((d) => d.toolCallId)),
+ mode: 'after',
+ snapshot: snapshotDays(trip.assignments, blockDays(touched)),
+ newPlaceCalls: applied.filter((id) => effects.get(id)?.place != null),
+ addedPlaceIds: [],
+ };
+}
+
+function compareActions(ctx: Ctx) {
+ return {
+ undoBlock: async (tripId: number, toolCallIds: readonly string[]): Promise => {
+ const result: UndoBlockResult = { done: 0, stale: 0, failed: 0 };
+ // One after the other and the last first: a later change may stand on an
+ // earlier one (a stop moved onto a day the block had just added it to).
+ // A refusal stops the run, so no earlier change falls while a later one
+ // that stands on it is still in place.
+ for (const toolCallId of [...toolCallIds].reverse()) {
+ const outcome = await undoOne(ctx, tripId, toolCallId);
+ if (outcome === 'undone') result.done += 1;
+ else if (outcome === 'stale') result.stale += 1;
+ else {
+ result.failed += 1;
+ break;
+ }
+ }
+ // The before/after switch stays while any of the block is still applied.
+ if (result.failed === 0 && result.stale === 0) patch(ctx, tripId, { compare: null });
+ return result;
+ },
+ setCompare: (tripId: number, mode: 'before' | 'after') => {
+ const compare = chat(ctx, tripId).compare;
+ if (!compare || compare.mode === mode) return;
+ patch(ctx, tripId, { compare: { ...compare, mode } });
+ // Switching is asking to see it: frame the block's days on both sides.
+ const days = Object.keys(compare.snapshot).map(Number);
+ const points = [
+ ...dayPoints(compare.snapshot, days),
+ ...dayPoints(useTripStore.getState().assignments, days),
+ ];
+ requestCameraOn(ctx, tripId, points, 'fit', 'user');
+ },
+ addFoundPlace: async (tripId: number, ref: string, dayId: number) => {
+ const threadId = chat(ctx, tripId).threadId;
+ if (chat(ctx, tripId).isRunning || threadId === null) return;
+ try {
+ const { message } = await assistantRepo.addFoundPlace(tripId, threadId, { ...clientContext(), ref, dayId });
+ const c = chat(ctx, tripId);
+ // The thread may have changed while the server answered. The server sends back the whole answer the
+ // card joined, under that answer's id, so it replaces the message it already holds.
+ if (c.threadId !== threadId) return;
+ const known = c.messages.some((m) => m.id === message.id);
+ const messages = known ? c.messages.map((m) => (m.id === message.id ? message : m)) : [...c.messages, message];
+ patch(ctx, tripId, { messages, error: null });
+ } catch (err) {
+ patch(ctx, tripId, { error: errorCodeOf(err) });
+ }
+ },
+ };
+}
+
+function requestCameraOn(ctx: Ctx, tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') {
+ if (points.length === 0) return;
+ const seq = (ctx.get().camera?.seq ?? 0) + 1;
+ ctx.set({ camera: { tripId, seq, points, mode, why } });
+}
+
+function mapActions(ctx: Ctx) {
+ return {
+ addMention: (tripId: number, mention: AssistantMentionItem) => {
+ const mentions = chat(ctx, tripId).mentions;
+ if (mentions.some((m) => m.kind === mention.kind && m.id === mention.id)) return;
+ patch(ctx, tripId, { mentions: [...mentions, mention] });
+ },
+ setMapHover: (tripId: number, hoveredId: string | null, source: AssistantMapSource) => {
+ const link = ctx.get().mapLink;
+ // Pointer moves repeat the same hover; only a change reaches the subscribers.
+ if (link?.tripId === tripId && link.hoveredId === hoveredId && link.source === source) return;
+ ctx.set({ mapLink: { tripId, hoveredId, source } });
+ },
+ requestCamera: (tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') =>
+ requestCameraOn(ctx, tripId, points, mode, why),
+ setPreviewPin: (preview: AssistantState['preview']) => ctx.set({ preview }),
+ setWindowRect: (rect: WindowRect | null) => ctx.set({ windowRect: rect }),
+ };
+}
+
+function turnActions(ctx: Ctx) {
+ const retry = async (tripId: number) => {
+ const c = chat(ctx, tripId);
+ if (c.isRunning) return;
+ if (c.unsent === null) {
+ // The server has the question but the answer broke off: ask it again.
+ const text = lastQuestion(c.messages);
+ if (text) await sendMessage(ctx, tripId, text);
+ return;
+ }
+ // The local copy of the refused question goes; sending puts it back.
+ const last = c.messages[c.messages.length - 1];
+ const refused = last?.role === 'user' && last.id.startsWith('local-');
+ patch(ctx, tripId, { messages: refused ? c.messages.slice(0, -1) : c.messages, unsent: null, error: null });
+ await sendMessage(ctx, tripId, c.unsent);
+ };
+
+ return {
+ send: (tripId: number, text: string) => sendMessage(ctx, tripId, text),
+ attach: (tripId: number, file: File) => attachFile(ctx, tripId, file),
+ decide: async (tripId: number, decisions: AssistantDecision[]) => {
+ const c = chat(ctx, tripId);
+ const threadId = c.threadId;
+ if (c.isRunning || threadId === null || decisions.length === 0) return;
+ const body = { ...clientContext(), decisions };
+ patch(ctx, tripId, { compare: compareFor(tripId, c.messages, decisions) });
+ await runTurn(ctx, tripId, {
+ messages: markDecisions(c.messages, decisions),
+ before: c.messages,
+ unsent: null,
+ open: (signal) => assistantRepo.decide(tripId, threadId, body, signal),
+ });
+ // Refused before anything ran: there is no "after" to compare with.
+ if (chat(ctx, tripId).error) patch(ctx, tripId, { compare: null });
+ },
+ undo: (tripId: number, toolCallId: string) => undoOne(ctx, tripId, toolCallId),
+ cancel: (tripId: number) => chat(ctx, tripId).abort?.abort(),
+ retry,
+ continueHere: async (tripId: number) => {
+ try {
+ await assistantRepo.stop(tripId);
+ } catch (err) {
+ patch(ctx, tripId, { error: errorCodeOf(err) });
+ return;
+ }
+ await retry(tripId);
+ },
+ dismissError: (tripId: number) => patch(ctx, tripId, { error: null }),
+ };
+}
+
+export const useAssistantStore = create((set, get) => {
+ const ctx: Ctx = { set, get };
+ return {
+ status: null,
+ window: { open: false, minimized: false },
+ byTrip: {},
+ lastApplied: null,
+ mapLink: null,
+ camera: null,
+ preview: null,
+ windowRect: null,
+ ...shellActions(ctx),
+ ...threadActions(ctx),
+ ...turnActions(ctx),
+ ...compareActions(ctx),
+ ...mapActions(ctx),
+ };
+});
diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts
index da14f88f4..82c78f366 100644
--- a/client/src/store/settingsStore.ts
+++ b/client/src/store/settingsStore.ts
@@ -91,6 +91,8 @@ export const DEFAULT_SETTINGS: Settings = {
map_provider: 'leaflet',
map_base_layer: 'default',
map_poi_pill_enabled: true,
+ assistant_map_follow: true,
+ assistant_hints: true,
carto_api_key: '',
mapbox_access_token: '',
mapbox_style: 'mapbox://styles/mapbox/standard',
diff --git a/client/src/types.ts b/client/src/types.ts
index 4296d9a61..582b50986 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -150,6 +150,10 @@ export interface Settings {
map_compact_unplanned?: boolean
map_booking_labels?: boolean
map_poi_pill_enabled?: boolean
+ /** The trip map pans to where the assistant searches and suggests places; absent reads as on. */
+ assistant_map_follow?: boolean
+ /** The assistant points out things in the plan worth a second look; absent reads as on. */
+ assistant_hints?: boolean
map_always_show_routes?: boolean
optimize_from_accommodation?: boolean
map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl'
diff --git a/client/src/utils/reducedMotion.test.ts b/client/src/utils/reducedMotion.test.ts
new file mode 100644
index 000000000..61fffee82
--- /dev/null
+++ b/client/src/utils/reducedMotion.test.ts
@@ -0,0 +1,27 @@
+// FE-UTIL-MOTION-001 to 002: the one reading of "reduce motion", the app's switch or the system's.
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { prefersReducedMotion } from './reducedMotion';
+
+function stubMedia(matches: boolean) {
+ vi.stubGlobal('matchMedia', (query: string) => ({ matches, media: query }) as MediaQueryList);
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ document.documentElement.removeAttribute('data-reduce-motion');
+});
+
+describe('prefersReducedMotion', () => {
+ it('FE-UTIL-MOTION-001: follows the system setting when the app says nothing', () => {
+ stubMedia(true);
+ expect(prefersReducedMotion()).toBe(true);
+ stubMedia(false);
+ expect(prefersReducedMotion()).toBe(false);
+ });
+
+ it("FE-UTIL-MOTION-002: the app's own switch wins over a system that allows motion", () => {
+ stubMedia(false);
+ document.documentElement.setAttribute('data-reduce-motion', '');
+ expect(prefersReducedMotion()).toBe(true);
+ });
+});
diff --git a/client/src/utils/reducedMotion.ts b/client/src/utils/reducedMotion.ts
new file mode 100644
index 000000000..14e1d2748
--- /dev/null
+++ b/client/src/utils/reducedMotion.ts
@@ -0,0 +1,15 @@
+const REDUCE_MOTION = '(prefers-reduced-motion: reduce)';
+
+/**
+ * Whether motion should be kept to a minimum: the app's own switch (the
+ * `data-reduce-motion` attribute the appearance settings put on ) or the
+ * system's. Read at the moment of asking, so a change takes effect on the next
+ * animation without a subscription. Without a window (tests, SSR) there is
+ * nothing to animate, so it answers true.
+ */
+export function prefersReducedMotion(): boolean {
+ if (typeof window === 'undefined') return true;
+ return (
+ document.documentElement.hasAttribute('data-reduce-motion') || (window.matchMedia?.(REDUCE_MOTION).matches ?? false)
+ );
+}
diff --git a/client/tests/helpers/storeRegistry.ts b/client/tests/helpers/storeRegistry.ts
index 0c89ec53e..b15187292 100644
--- a/client/tests/helpers/storeRegistry.ts
+++ b/client/tests/helpers/storeRegistry.ts
@@ -1,5 +1,6 @@
import type { StoreApi } from 'zustand';
import { useAddonStore } from '../../src/store/addonStore';
+import { useAssistantHintsStore } from '../../src/store/assistantHintsStore';
import { useAuthStore } from '../../src/store/authStore';
import { useInAppNotificationStore } from '../../src/store/inAppNotificationStore';
import { usePaddleObstacleStore } from '../../src/store/paddleObstacleStore';
@@ -42,6 +43,7 @@ export const RESET_STORES: Record = {
useTourMapViewportStore,
usePaddleObstacleStore,
useTrackMarkToolStore,
+ useAssistantHintsStore,
};
/** Not reset so far; moving one into RESET_STORES changes what every file that resets starts from. */
@@ -49,6 +51,8 @@ const NOT_YET = 'never reset by resetAllStores; its own tests set the state they
export const UNRESET_STORES: Record = {
useJourneyStore: 'reset in the journey tests themselves; importing it here pulls in a circular import',
+ useAssistantStore:
+ 'the assistant chats, window and live map links; the tests that read it put back its first state themselves',
useBackgroundTasksStore: NOT_YET,
useCollectionStore: NOT_YET,
useCollectionTourStore:
diff --git a/client/tests/unit/mobile/trip/MAssistantScreen.test.tsx b/client/tests/unit/mobile/trip/MAssistantScreen.test.tsx
new file mode 100644
index 000000000..ef2c32067
--- /dev/null
+++ b/client/tests/unit/mobile/trip/MAssistantScreen.test.tsx
@@ -0,0 +1,207 @@
+import type { AiStatusResponse, AssistantApplied } from '@trek/shared';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import MAssistantScreen from '../../../../src/mobile/screens/trip/assistant/MAssistantScreen';
+import { chatOf, useAssistantStore } from '../../../../src/store/assistantStore';
+import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
+import { act, cleanup, fireEvent, render, screen } from '../../../helpers/render';
+
+// FE-MOB-ASSIST-001 to FE-MOB-ASSIST-011
+//
+// The phone's full-screen chat around the shared chat surface, which is
+// replaced here: what is tested is the frame, the header and the way to the
+// map and back.
+
+const surface = vi.hoisted(() => ({
+ onShowOnMap: null as ((applied: AssistantApplied) => void) | null,
+ onFocusDay: null as ((dayId: number) => void) | null,
+ onRevealMap: null as (() => void) | null,
+}));
+const thread = vi.hoisted(() => ({
+ threadId: 3 as number | null,
+ threads: [{ id: 3, title: 'Temples on day 2', lastMessageAt: '2026-10-20T09:00:00.000Z' }],
+ title: 'Temples on day 2' as string | null,
+ isRunning: false,
+ retentionNote: 'Chats are deleted after 90 days.',
+ refreshThreads: vi.fn(),
+ openThread: vi.fn(),
+ newThread: vi.fn(),
+ removeThread: vi.fn(async () => undefined),
+ stop: vi.fn(),
+}));
+
+vi.mock('../../../../src/components/Assistant/AssistantSurfaceLazy', () => ({
+ default: (props: {
+ onShowOnMap?: (applied: AssistantApplied) => void;
+ onFocusDay?: (dayId: number) => void;
+ onRevealMap?: () => void;
+ }) => {
+ surface.onShowOnMap = props.onShowOnMap ?? null;
+ surface.onFocusDay = props.onFocusDay ?? null;
+ surface.onRevealMap = props.onRevealMap ?? null;
+ return ;
+ },
+}));
+
+vi.mock('../../../../src/components/Assistant/useAssistantThread', () => ({
+ useAssistantThread: () => thread,
+}));
+
+const INITIAL = useAssistantStore.getState();
+const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
+
+function renderScreen(plannerOverrides = {}, shellOverrides = {}) {
+ const planner = buildPlanner(plannerOverrides);
+ const shell = buildShell(shellOverrides);
+ const view = render();
+ return { planner, shell, ...view };
+}
+
+beforeEach(() => {
+ // Closing the screen takes its history entry back a tick later; that step stays queued here.
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ window.history.replaceState(null, '');
+ // The screen asks for the status itself; here it is already known.
+ useAssistantStore.setState({
+ status: STATUS,
+ window: { open: true, minimized: false },
+ loadStatus: vi.fn(async () => undefined),
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ useAssistantStore.setState(INITIAL, true);
+ vi.clearAllMocks();
+});
+
+describe('MAssistantScreen', () => {
+ it('FE-MOB-ASSIST-001: shows nothing until the assistant has a connection to use', () => {
+ useAssistantStore.setState({ status: { ...STATUS, available: false } });
+ const { container } = renderScreen();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('FE-MOB-ASSIST-002: asks for the status once when the trip opens', () => {
+ renderScreen();
+ expect(useAssistantStore.getState().loadStatus).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-MOB-ASSIST-003: opens full screen with the chat, named after the conversation and the trip', () => {
+ renderScreen();
+ expect(screen.getByRole('dialog', { name: 'Assistant' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Temples on day 2' })).toBeInTheDocument();
+ expect(screen.getByText('Japan 2026')).toBeInTheDocument();
+ expect(screen.getByTestId('chat-surface')).toBeInTheDocument();
+ });
+
+ it('FE-MOB-ASSIST-004: the back button closes the chat', () => {
+ renderScreen();
+ fireEvent.click(screen.getByRole('button', { name: 'Back' }));
+ expect(useAssistantStore.getState().window.open).toBe(false);
+ });
+
+ it('FE-MOB-ASSIST-005: the recent chats list switches the conversation and names how long chats are kept', () => {
+ renderScreen();
+ fireEvent.click(screen.getByRole('button', { name: 'Recent chats' }));
+ expect(thread.refreshThreads).toHaveBeenCalled();
+ expect(screen.getByText('Chats are deleted after 90 days.')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /Temples on day 2/ }));
+ expect(thread.openThread).toHaveBeenCalledWith(3);
+ });
+
+ it('FE-MOB-ASSIST-006: deleting the chat asks first', () => {
+ renderScreen();
+ fireEvent.click(screen.getByRole('button', { name: 'More options' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Delete chat' }));
+ expect(thread.removeThread).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
+ expect(thread.removeThread).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-MOB-ASSIST-007: "Show on map" steps aside for the map, and the pill brings the chat back as it was', () => {
+ const { planner, shell } = renderScreen({}, { trTab: 'listen', view: 'plan' });
+ act(() => surface.onShowOnMap?.({ toolCallId: 'c1', entity: 'place', ids: [42], dayId: 2, undoUntil: null }));
+
+ expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: true });
+ expect(shell.setTrTab).toHaveBeenCalledWith('plan');
+ expect(shell.toggleView).toHaveBeenCalled();
+ expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(42);
+ // Hidden, not gone: the answer being written keeps coming in.
+ expect(screen.getByTestId('chat-surface')).toBeInTheDocument();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Back to chat' }));
+ expect(useAssistantStore.getState().window.minimized).toBe(false);
+ expect(screen.getByRole('dialog', { name: 'Assistant' })).toBeInTheDocument();
+ });
+
+ it('FE-MOB-ASSIST-009: a day picked in the chat is selected on the map, and the pill says where a step looks', () => {
+ const { planner, shell } = renderScreen({}, { trTab: 'listen', view: 'plan' });
+ act(() => surface.onFocusDay?.(7));
+ expect(useAssistantStore.getState().window.minimized).toBe(true);
+ expect(shell.setTrTab).toHaveBeenCalledWith('plan');
+ expect(planner.handleSelectDay).toHaveBeenCalledWith(7, false);
+
+ act(() =>
+ useAssistantStore.setState((s) => ({
+ byTrip: {
+ ...s.byTrip,
+ [planner.tripId]: {
+ ...chatOf(s, planner.tripId),
+ messages: [
+ {
+ id: 'msg-1',
+ role: 'assistant',
+ status: 'complete',
+ createdAt: '2026-10-21T08:00:00.000Z',
+ parts: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search_nearby_places', args: {} }],
+ },
+ ],
+ live: { activity: { toolCallId: 'c1', label: 'Gion', area: null } },
+ },
+ },
+ }))
+ );
+ expect(screen.getByRole('button', { name: 'Back to chat' })).toHaveTextContent('Searching near Gion');
+ });
+
+ it('FE-MOB-ASSIST-010: before the map moves to a found place, the chat steps aside', () => {
+ const { shell } = renderScreen({}, { trTab: 'plan', view: 'plan' });
+ act(() => surface.onRevealMap?.());
+ expect(useAssistantStore.getState().window.minimized).toBe(true);
+ expect(shell.toggleView).toHaveBeenCalled();
+ });
+
+ it('FE-MOB-ASSIST-011: while a block can be compared, its switch sits beside the pill over the map', () => {
+ const { planner } = renderScreen({}, { trTab: 'plan', view: 'plan' });
+ act(() => surface.onRevealMap?.());
+ expect(screen.queryByRole('group', { name: 'Compare on the map' })).not.toBeInTheDocument();
+
+ act(() =>
+ useAssistantStore.setState((s) => ({
+ byTrip: {
+ ...s.byTrip,
+ [planner.tripId]: {
+ ...chatOf(s, planner.tripId),
+ compare: { blockKey: 'c1', mode: 'after', snapshot: {}, newPlaceCalls: [], addedPlaceIds: [] },
+ },
+ },
+ }))
+ );
+ expect(screen.getByRole('group', { name: 'Compare on the map' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'After' })).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('FE-MOB-ASSIST-008: the back gesture closes the chat instead of leaving the trip', () => {
+ renderScreen();
+ expect(window.history.state).toMatchObject({ trekAssistant: true });
+ act(() => {
+ window.history.replaceState({ idx: 0 }, '');
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ });
+ expect(useAssistantStore.getState().window.open).toBe(false);
+ });
+});
diff --git a/client/tests/unit/mobile/trip/MCollabChat.test.tsx b/client/tests/unit/mobile/trip/MCollabChat.test.tsx
index 2d36cc724..6c6bdb9b4 100644
--- a/client/tests/unit/mobile/trip/MCollabChat.test.tsx
+++ b/client/tests/unit/mobile/trip/MCollabChat.test.tsx
@@ -168,6 +168,14 @@ describe('MCollabChat', () => {
expect(screen.getByText('plain')).toHaveClass('whitespace-pre-wrap')
})
+ it('FE-MOB-CCHAT-034: a shared assistant answer carries its label, a typed message none', async () => {
+ serveMessages([chatMsg(1, { text: 'typed' }), chatMsg(2, { text: 'shared', origin: 'assistant' })])
+ await renderChat()
+ expect(screen.getAllByText('from the assistant')).toHaveLength(1)
+ expect(screen.getByText('shared').closest('.flex-col')).toHaveTextContent('from the assistant')
+ expect(screen.getByText('typed').closest('.flex-col')).not.toHaveTextContent('from the assistant')
+ })
+
it('FE-MOB-CCHAT-008: shows the quoted message above a reply bubble', async () => {
serveMessages([
chatMsg(1, { text: 'Where do we meet?' }),
diff --git a/client/tests/unit/mobile/trip/MMehrSheet.test.tsx b/client/tests/unit/mobile/trip/MMehrSheet.test.tsx
index 763752b01..78a14cfe2 100644
--- a/client/tests/unit/mobile/trip/MMehrSheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MMehrSheet.test.tsx
@@ -1,13 +1,17 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FolderOpen, Users } from 'lucide-react'
+import type { AiStatusResponse } from '@trek/shared'
import MMehrSheet from '../../../../src/mobile/screens/trip/sheets/MMehrSheet'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { TripFile, TripMember } from '../../../../src/types'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
+import { useAssistantStore } from '../../../../src/store/assistantStore'
+import { useTripStore } from '../../../../src/store/tripStore'
+import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../helpers/factories'
import { fireEvent, render, screen, within } from '../../../helpers/render'
-// FE-MOB-MEHR-001 to FE-MOB-MEHR-014
+// FE-MOB-MEHR-001 to FE-MOB-MEHR-017
//
// The sheet takes its copy from the real TranslationProvider, so the visible
// strings are asserted in English.
@@ -175,4 +179,45 @@ describe('MMehrSheet', () => {
expect(screen.getByRole('button', { name: 'Trip settings' }).parentElement).not.toHaveClass('mt-2')
})
})
+
+ describe('the assistant tile', () => {
+ const initial = useAssistantStore.getState()
+ afterEach(() => useAssistantStore.setState(initial, true))
+
+ it('FE-MOB-MEHR-015: is not there until the assistant has a connection to use', () => {
+ renderSheet()
+ expect(screen.queryByRole('button', { name: /Ask TREK/ })).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-MEHR-016: leads the sheet and opens the chat in place of it', () => {
+ useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
+ const { shell } = renderSheet()
+ fireEvent.click(screen.getByRole('button', { name: /Ask TREK/ }))
+ expect(shell.closeSheet).toHaveBeenCalledTimes(1)
+ expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false })
+ })
+
+ it('FE-MOB-MEHR-017: counts the warnings in the plan not seen yet, and opening the chat counts them seen', () => {
+ window.localStorage.clear()
+ useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
+ const places = [
+ buildPlace({ id: 70, place_time: '10:00', end_time: '12:00' }),
+ buildPlace({ id: 71, place_time: '11:00', end_time: '12:30' }),
+ ]
+ useTripStore.setState({
+ trip: buildTrip({ id: 1, start_date: '2099-06-01', end_date: '2099-06-01' }),
+ days: [buildDay({ id: 1, date: '2099-06-01' })],
+ places,
+ assignments: {
+ '1': places.map((place, i) => buildAssignment({ id: 80 + i, place_id: place.id, order_index: i, place })),
+ },
+ reservations: [],
+ dayNotes: {},
+ })
+ renderSheet()
+ expect(screen.getByText('1 thing to check in your plan')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /Ask TREK/ }))
+ expect(screen.queryByText('1 thing to check in your plan')).not.toBeInTheDocument()
+ })
+ })
})
diff --git a/client/tests/unit/stores/addonStore.test.ts b/client/tests/unit/stores/addonStore.test.ts
index 03a0fec61..f4c71d34b 100644
--- a/client/tests/unit/stores/addonStore.test.ts
+++ b/client/tests/unit/stores/addonStore.test.ts
@@ -43,6 +43,20 @@ describe('addonStore', () => {
await useAddonStore.getState().loadAddons();
expect(useAddonStore.getState().toursWanderer).toBe(false);
});
+
+ it('keeps the collab chat on unless the response turns it off', async () => {
+ server.use(http.get('/api/addons', () => HttpResponse.json({ addons: [] })));
+ await useAddonStore.getState().loadAddons();
+ expect(useAddonStore.getState().collabChat).toBe(true);
+
+ server.use(http.get('/api/addons', () => HttpResponse.json({ addons: [], collabFeatures: { chat: true } })));
+ await useAddonStore.getState().loadAddons();
+ expect(useAddonStore.getState().collabChat).toBe(true);
+
+ server.use(http.get('/api/addons', () => HttpResponse.json({ addons: [], collabFeatures: { chat: false } })));
+ await useAddonStore.getState().loadAddons();
+ expect(useAddonStore.getState().collabChat).toBe(false);
+ });
});
describe('FE-ADDON-002: isEnabled returns true for known addon', () => {
diff --git a/client/vite.config.js b/client/vite.config.js
index 40f449bdc..9863e3cda 100644
--- a/client/vite.config.js
+++ b/client/vite.config.js
@@ -79,7 +79,9 @@ export default defineConfig(({ mode }) => ({
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
// build:analyze drops a treemap next to the app; it must never end up in a
// precache manifest if someone ships that build by accident.
- globIgnores: ['**/stats.html'],
+ // The assistant's chat (@assistant-ui, about 110 kB gzip) only runs online, so
+ // installing it for every user offline would be pure cost; it loads when opened.
+ globIgnores: ['**/stats.html', '**/AssistantSurface-*.js'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [
/^\/api/,
diff --git a/package-lock.json b/package-lock.json
index bb627b8cb..b80b9beed 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,6 +32,7 @@
"version": "4.3.3",
"hasInstallScript": true,
"dependencies": {
+ "@assistant-ui/react": "0.15.25",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
@@ -227,6 +228,118 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@assistant-ui/core": {
+ "version": "0.3.30",
+ "resolved": "https://registry.npmjs.org/@assistant-ui/core/-/core-0.3.30.tgz",
+ "integrity": "sha512-DuCdjTYwFSOwxeBGtJ71l6Fe2tlEIq3aFOgF94yi5lrKbA3lnzfCsxdEUOq5z/w19Eo58jyYERMonulM+O/P4g==",
+ "license": "MIT",
+ "dependencies": {
+ "assistant-cloud": "^0.2.7",
+ "assistant-stream": "^0.3.53",
+ "nanoid": "^6.0.2"
+ },
+ "peerDependencies": {
+ "@assistant-ui/store": "^0.3.22",
+ "@assistant-ui/tap": "^0.9.24",
+ "@types/react": "*",
+ "react": "^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@assistant-ui/core/node_modules/nanoid": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.2.tgz",
+ "integrity": "sha512-hIvVOb9Qr1p/9CNNLOe0OlWvgx/ziTsVwKN/GDSsuArN2K0wDiafP3s0SWmsm7o2BdnyS8Z9Z64NFBUbn6ziBg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^22 || ^24 || >=26"
+ }
+ },
+ "node_modules/@assistant-ui/react": {
+ "version": "0.15.25",
+ "resolved": "https://registry.npmjs.org/@assistant-ui/react/-/react-0.15.25.tgz",
+ "integrity": "sha512-uKAtDwMbdF2NY/F1L/sQNuNGU85tf7eyNSCZ2lBX4yddbsjP5FB3v46HxojRiIoeEyxsqe+6GS8p/RBb/YImuQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@assistant-ui/core": "^0.3.24",
+ "@assistant-ui/store": "^0.3.17",
+ "@assistant-ui/tap": "^0.9.21",
+ "assistant-cloud": "^0.2.5",
+ "assistant-stream": "^0.3.48",
+ "radix-ui": "^1.6.7",
+ "react-textarea-autosize": "^8.5.9",
+ "safe-content-frame": "^0.0.33",
+ "zod": "^4.6.5",
+ "zustand": "^5.0.15"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^18 || ^19",
+ "react-dom": "^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@assistant-ui/store": {
+ "version": "0.3.22",
+ "resolved": "https://registry.npmjs.org/@assistant-ui/store/-/store-0.3.22.tgz",
+ "integrity": "sha512-aXp4m42mmtZOQUE0lzxJ8Z9EZKknOWBk+DDg1Pj2gNhhBGYXmfrScYD1N1e/9/8wTKSwL0g4z2fSZqr+5fWbxg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@assistant-ui/tap": "^0.9.24",
+ "@types/react": "*",
+ "react": "^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@assistant-ui/tap": {
+ "version": "0.9.24",
+ "resolved": "https://registry.npmjs.org/@assistant-ui/tap/-/tap-0.9.24.tgz",
+ "integrity": "sha512-6O0hf2HkKbrylIwNlFUimHf2V/6Gq5UXF0y0r2Fbw5wSvvO8ZDmv0AEkMwwygSfJgbpnQgiC8Xs+2VFaeO0RBw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@aws-lite/client": {
"version": "0.23.7",
"resolved": "https://registry.npmjs.org/@aws-lite/client/-/client-0.23.7.tgz",
@@ -2163,7 +2276,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -3215,6 +3327,44 @@
}
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
+ "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
+ "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.8.0",
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
+ "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.8.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
+ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
+ "license": "MIT"
+ },
"node_modules/@fontsource/bebas-neue": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.3.0.tgz",
@@ -7174,6 +7324,1509 @@
"url": "https://github.com/sponsors/sxzz"
}
},
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz",
+ "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
+ "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-accessible-icon": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.2.0.tgz",
+ "integrity": "sha512-BB4+HHkFl3HipEAhm/mLhrpXV+2kRX7MvY4AaImUcQVeWQt5KhV1JEByNs8NGfJOcr95RcQGijt9+OrCo68xGA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-visually-hidden": "1.2.13"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-accordion": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.3.1.tgz",
+ "integrity": "sha512-lCZ6sbGS/7WO0j84yx4srIgOf0MRJ/sZ13RonADXxvKBym8xrxNCq5f6DibGcr38sIlwPk8/52uYdXeOPsbEDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collapsible": "1.2.1",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.2.1.tgz",
+ "integrity": "sha512-MWC4N7RY+qsbNrFNo7yQ9ZbdoZEW+B5DtpWxJfUnVCqcQxxqqg3qis3RMPTDbCNaTIdHJSn5XDkl9ZdVWXknLg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-dialog": "1.3.1",
+ "@radix-ui/react-primitive": "2.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.17",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.17.tgz",
+ "integrity": "sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-aspect-ratio": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.2.0.tgz",
+ "integrity": "sha512-gHEU+Lx8DlT/FYIjZQvvBJrFSwUngXTjh7FeL9QvJaNALDLez6pgDj2axCUihBjtPBy82LN0gNb25+sTcSXNmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-avatar": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.3.1.tgz",
+ "integrity": "sha512-ssZOkfQNkFpYVmCPQlr978xSaU7moSrGXdwg4j2Xs2WlGl/1KsSZtfpK+WnLnx0Rwll1BAh6oUrAy/uR31SBvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-is-hydrated": "0.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-checkbox": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.4.1.tgz",
+ "integrity": "sha512-KWtwhh+98ML3seZ9vO9yRY2OwaQG90dCiDAxjpSNY2AaZ3Hik3B0dsk6m0ILywo/no3A9/SozadyPMNweVT8yw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-size": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.2.1.tgz",
+ "integrity": "sha512-fSzLb/qy1wuZSxQBcxpW0hNXYHDMWiIXDGyJGdA4+FSLknOWZsNRrTCUavxb4PI8b8lvHo/CysO6NVi5zLpo5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.2.0.tgz",
+ "integrity": "sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
+ "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz",
+ "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.4.1.tgz",
+ "integrity": "sha512-Pddn3Z8lio3/ChxyXaEP3jHTTo1LTHCWwF8+mG1chQlWijP+ttVHBKwezrQTiV6Mj670ojvumqvuGh+mdv33iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-menu": "2.2.1",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz",
+ "integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-focus-guards": "1.2.0",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.2.0.tgz",
+ "integrity": "sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz",
+ "integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-effect-event": "0.0.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.2.1.tgz",
+ "integrity": "sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-menu": "2.2.1",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz",
+ "integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz",
+ "integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-form": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.3.0.tgz",
+ "integrity": "sha512-KnmrCM27m7Jo1R8S6cFDPrEeq20Jfn5dqde0SZrJr2lVXlj6/QZSUCyFoYh1d8ti0uUrMNK5Lx6dUDuINT+f4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-label": "2.1.17",
+ "@radix-ui/react-primitive": "2.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-hover-card": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.2.1.tgz",
+ "integrity": "sha512-4QooHxgmpCKRjj1Pft+hETKidYcrbPKcVwMGtk2Oa5DcWY2IH1fHuQt9LG6q5hUQBBV2MhQxSvtx6B/C1xfiTw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-popper": "1.4.0",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
+ "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.1.17",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.17.tgz",
+ "integrity": "sha512-xmj7MN/drx1oUAXkfiFIvyDmGNUIN/omGobvA5oAoNqu9zO2MkISYGZOkBcqSzs/UZXXwV+/3ACRE7q7mGcmkA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.2.1.tgz",
+ "integrity": "sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-focus-guards": "1.2.0",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-popper": "1.4.0",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-roving-focus": "1.2.1",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menubar": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.2.1.tgz",
+ "integrity": "sha512-bRTYtS9JITV2dUYqYZb7+N5SooQBkZrNRejfmyrSKe3Y57aXhKgx//kBUP0kY7pEpsWR/U1AHsAJRzjwCrK3PA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-menu": "2.2.1",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-roving-focus": "1.2.1",
+ "@radix-ui/react-use-controllable-state": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-navigation-menu": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.4.1.tgz",
+ "integrity": "sha512-XPPZjkeyCSMNNvt0FyDv/PUauG7Ow4GwHQ/XZSwW36F/TS7rR4HFTsXH8ewLnXTOLn3x96/pmTPNQIVoTX/86g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-use-previous": "1.1.4",
+ "@radix-ui/react-visually-hidden": "1.2.13"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-one-time-password-field": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.2.1.tgz",
+ "integrity": "sha512-KtHdcgnaAK6rohQ/MMJoGYcbgSRJsIuGBdHrFeTB1/9SaKlDs14oSOniW6BL6M43HqvgCO3u4WuVdFLkuc6sqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.3",
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-roving-focus": "1.2.1",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-effect-event": "0.0.5",
+ "@radix-ui/react-use-is-hydrated": "0.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-password-toggle-field": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.2.1.tgz",
+ "integrity": "sha512-vu4+ZoJAaFjdI7l1lDSZRs+4dEv83Jmu7lyzajPrAeQzX74y84n0rUNWbWSgN9XB5NE8IY86Z02kGH6oRrzR+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-effect-event": "0.0.5",
+ "@radix-ui/react-use-is-hydrated": "0.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.3.1.tgz",
+ "integrity": "sha512-ZNxvXRx5W2Wos4fXCXb5lpdqN0Z5chf+mStOUOTr4ik7d+62MRvoe9rzCA/eGNg36lxXqSrKEKmwuZEcUrIstw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-focus-guards": "1.2.0",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-popper": "1.4.0",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.4.0.tgz",
+ "integrity": "sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.17",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-use-rect": "1.1.4",
+ "@radix-ui/react-use-size": "1.1.5",
+ "@radix-ui/rect": "1.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz",
+ "integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
+ "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
+ "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.5.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-progress": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.2.1.tgz",
+ "integrity": "sha512-yQBiM6J3YM8Ums1orkEFpbznK47geKxa0igikzcaS1xHgdba3DdAQBsGbCTt5BVfR8zhEvgxQ0poGcINaD3lvw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-primitive": "2.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.5.1.tgz",
+ "integrity": "sha512-QbyVT1ddnjqB9H+CUYKkNWdU9fIK3QHCYtjf7mS6ekSqiuXkWtWn5UZW9mhA8qtl3vrVyIsmR9gePstAtKrqkw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-roving-focus": "1.2.1",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-size": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.2.1.tgz",
+ "integrity": "sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-is-hydrated": "0.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-scroll-area": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.4.0.tgz",
+ "integrity": "sha512-CFrLqHNejWq5FhKAcj9V/WPGRsol76HBQto81NdOBVFEOqIEkJkKIJt0Z824A/9hHLCtoiR/Kw3euRgzXP2pxg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.3",
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.4.1.tgz",
+ "integrity": "sha512-888ZwW0Md6GyptuqYABRX9nLKgoYfcooCyBBvVaX5Jp3e+8Jp2i4Zo/Uy50Hm+Ysn2JfloQXstW4U36w8teVtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.3",
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-focus-guards": "1.2.0",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-popper": "1.4.0",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-visually-hidden": "1.2.13",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.2.0.tgz",
+ "integrity": "sha512-1jUAap0wZEqVP05Ou7kJgRxe/W+Qftng+rUNWGa0qkKbv9r5pWTG3E7a3ELKo2eJoHKMECnzuYrZpSyO1YgcYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slider": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.6.1.tgz",
+ "integrity": "sha512-IRNRR90moPqxfwCJ0XbMJIxo70W0/1ZExT/sNCxEHMaPx0SF9HoQrcPEC75+H28cFKa0b+KH63anfLYJmwKacw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.3",
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-use-size": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
+ "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.4.1.tgz",
+ "integrity": "sha512-NpBGwc40rY02ZmSvuyeKk0Bvc8ZsNeTMi4qHvJ+ZH4EiYCv0zIXEgdCW4bVdzgIUeP9xxbLJSxKOUfqITYNmkA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-size": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tabs": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.2.1.tgz",
+ "integrity": "sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-roving-focus": "1.2.1",
+ "@radix-ui/react-use-controllable-state": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toast": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.3.1.tgz",
+ "integrity": "sha512-iQvL0J7XK4HtJNGkkWMyYrboNseI7vtotFeUkIno8YNxVA9oM7B6//fh0Mc6TSKI96+zioLWi98lQLso88yJmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-visually-hidden": "1.2.13"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toggle": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.2.1.tgz",
+ "integrity": "sha512-+3MUq+WyiBplXYqQ0EIYlq8R4sLQ0T5NbOK7DMdDLCKO93pe2uE0DUJTWWfTBprpJJXxfoF3eMW0xpakVZrYsg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toggle-group": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.2.1.tgz",
+ "integrity": "sha512-O8Ldx5ao4p4KoW+DO8/PmkOe5mdKNi3F2SseqjVj88wvDGDHe60cUmO0Zm/uHsKrvNgQGV8Efpuh95Tht/h4vw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-roving-focus": "1.2.1",
+ "@radix-ui/react-toggle": "1.2.1",
+ "@radix-ui/react-use-controllable-state": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toolbar": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.2.1.tgz",
+ "integrity": "sha512-Cm8m7JTEeUdbfA6T9xgyu8HfOpyvSx8pB/wj0DlEuWaJe9FilIs4Az9wci/lU9RbthV/UkltszMw5WnnD+m/Ug==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-roving-focus": "1.2.1",
+ "@radix-ui/react-separator": "1.2.0",
+ "@radix-ui/react-toggle-group": "1.2.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.4.1.tgz",
+ "integrity": "sha512-NxLFGSFtZ78zVfcoOhTwR1xwqo5qx1Jh8Pw6FMZ9McCaqfRzz7/gIRahs2Oy5XlPgQ+8QkUcKbN5B0U44J/kCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-popper": "1.4.0",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-visually-hidden": "1.2.13"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz",
+ "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz",
+ "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-use-effect-event": "0.0.5",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
+ "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.6.tgz",
+ "integrity": "sha512-9cRullnQRv+kqnRmFlmh0WC3kAPuGmHiQ9VwsXdrYk//sBhgCKEttcpPEPgIQbyx6TGUmooVEHYTyohwz/lSJg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-is-hydrated": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz",
+ "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
+ "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz",
+ "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz",
+ "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/rect": "1.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz",
+ "integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.13.tgz",
+ "integrity": "sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz",
+ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
+ "license": "MIT"
+ },
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
@@ -8077,7 +9730,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
- "dev": true,
"license": "MIT"
},
"node_modules/@swc/core": {
@@ -8734,7 +10386,6 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
@@ -8859,7 +10510,7 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.3.0"
@@ -9983,6 +11634,18 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -10069,6 +11732,81 @@
"node": ">=12"
}
},
+ "node_modules/assistant-cloud": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.2.7.tgz",
+ "integrity": "sha512-w9Nq03JwNVkQovlStrIQb22K/VIyuZ7ioTzjpQTLIggFdL1dUz++DEA1W3VYTFzSHIptseuHvaWSUM05hs5uVg==",
+ "license": "MIT",
+ "dependencies": {
+ "assistant-stream": "^0.3.49"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/exporter-trace-otlp-http": ">=0.200.0",
+ "@opentelemetry/sdk-trace-base": "^2.1.0",
+ "ai": "^6.0.0 || ^7.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@opentelemetry/exporter-trace-otlp-http": {
+ "optional": true
+ },
+ "@opentelemetry/sdk-trace-base": {
+ "optional": true
+ },
+ "ai": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/assistant-stream": {
+ "version": "0.3.53",
+ "resolved": "https://registry.npmjs.org/assistant-stream/-/assistant-stream-0.3.53.tgz",
+ "integrity": "sha512-H04PNl8n76Cu+2EiWXwRvb1k+oT6HRA7/gnlJ7Yp4VAI+nBbg92w88V0T3qVF+pzR01gaEr+x18noMCPNd9j9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/json-schema": "^7.0.15",
+ "nanoid": "^6.0.2",
+ "secure-json-parse": "^4.1.0"
+ },
+ "peerDependencies": {
+ "ai": "^6.0.0 || ^7.0.0",
+ "ioredis": "^5.10.1 || ^6.0.0",
+ "redis": "^5.12.1"
+ },
+ "peerDependenciesMeta": {
+ "ai": {
+ "optional": true
+ },
+ "ioredis": {
+ "optional": true
+ },
+ "redis": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/assistant-stream/node_modules/nanoid": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.2.tgz",
+ "integrity": "sha512-hIvVOb9Qr1p/9CNNLOe0OlWvgx/ziTsVwKN/GDSsuArN2K0wDiafP3s0SWmsm7o2BdnyS8Z9Z64NFBUbn6ziBg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^22 || ^24 || >=26"
+ }
+ },
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
@@ -11551,6 +13289,12 @@
"node": ">=8"
}
},
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -13153,6 +14897,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/get-own-enumerable-property-symbols": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
@@ -18596,6 +20349,83 @@
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
+ "node_modules/radix-ui": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.8.1.tgz",
+ "integrity": "sha512-HPfpVGizO9xANlsFSauruLhRe/3+iOt3vPX0MXYle/UQi3gorQrSYtBcdiv0BZYewcl2rvL59qrIuLFMGAmUrw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-accessible-icon": "1.2.0",
+ "@radix-ui/react-accordion": "1.3.1",
+ "@radix-ui/react-alert-dialog": "1.2.1",
+ "@radix-ui/react-arrow": "1.1.17",
+ "@radix-ui/react-aspect-ratio": "1.2.0",
+ "@radix-ui/react-avatar": "1.3.1",
+ "@radix-ui/react-checkbox": "1.4.1",
+ "@radix-ui/react-collapsible": "1.2.1",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-context-menu": "2.4.1",
+ "@radix-ui/react-dialog": "1.3.1",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-dropdown-menu": "2.2.1",
+ "@radix-ui/react-focus-guards": "1.2.0",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-form": "0.3.0",
+ "@radix-ui/react-hover-card": "1.2.1",
+ "@radix-ui/react-label": "2.1.17",
+ "@radix-ui/react-menu": "2.2.1",
+ "@radix-ui/react-menubar": "1.2.1",
+ "@radix-ui/react-navigation-menu": "1.4.1",
+ "@radix-ui/react-one-time-password-field": "0.2.1",
+ "@radix-ui/react-password-toggle-field": "0.2.1",
+ "@radix-ui/react-popover": "1.3.1",
+ "@radix-ui/react-popper": "1.4.0",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-progress": "1.2.1",
+ "@radix-ui/react-radio-group": "1.5.1",
+ "@radix-ui/react-roving-focus": "1.2.1",
+ "@radix-ui/react-scroll-area": "1.4.0",
+ "@radix-ui/react-select": "2.4.1",
+ "@radix-ui/react-separator": "1.2.0",
+ "@radix-ui/react-slider": "1.6.1",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-switch": "1.4.1",
+ "@radix-ui/react-tabs": "1.2.1",
+ "@radix-ui/react-toast": "1.3.1",
+ "@radix-ui/react-toggle": "1.2.1",
+ "@radix-ui/react-toggle-group": "1.2.1",
+ "@radix-ui/react-toolbar": "1.2.1",
+ "@radix-ui/react-tooltip": "1.4.1",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-effect-event": "0.0.5",
+ "@radix-ui/react-use-escape-keydown": "1.1.6",
+ "@radix-ui/react-use-is-hydrated": "0.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-use-size": "1.1.5",
+ "@radix-ui/react-visually-hidden": "1.2.13"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -18751,6 +20581,53 @@
"react": ">=18"
}
},
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+ "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-router": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
@@ -18792,6 +20669,45 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-textarea-autosize": {
+ "version": "8.5.9",
+ "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
+ "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.20.13",
+ "use-composed-ref": "^1.3.0",
+ "use-latest": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -19420,6 +21336,12 @@
],
"license": "MIT"
},
+ "node_modules/safe-content-frame": {
+ "version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/safe-content-frame/-/safe-content-frame-0.0.33.tgz",
+ "integrity": "sha512-JvBLoQu8rhu80MwijhnJ2zA057uCmatyOxqLbcfpMZ3XM2bI+lmu0x7e1ne92m9wHGi4eB25VcJMnSDeoQNg6w==",
+ "license": "MIT"
+ },
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -19482,6 +21404,22 @@
"node": ">=v12.22.7"
}
},
+ "node_modules/secure-json-parse": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.1.tgz",
+ "integrity": "sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -21412,6 +23350,94 @@
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
"license": "MIT"
},
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-composed-ref": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
+ "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-isomorphic-layout-effect": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
+ "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-latest": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
+ "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "use-isomorphic-layout-effect": "^1.1.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/utif2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts
index d6ffe1323..d39937dee 100644
--- a/server/scripts/migrate-encryption.ts
+++ b/server/scripts/migrate-encryption.ts
@@ -634,6 +634,26 @@ async function main() {
}
}
+ // --- ai_connections: secrets ---
+ // The AI assistant's connections keep their credential in ONE encrypted
+ // JSON blob (src/nest/ai-connections/ai-secrets.ts): an API key, or a
+ // ChatGPT sign-in's tokens. Left out of a rotation, the instance's
+ // connection asks the admin for its key again and every ChatGPT user has
+ // to sign in anew. The table arrived with a later migration, hence the
+ // existence check.
+ if (tableExists('ai_connections')) {
+ const aiConnections = db.prepare('SELECT id, secrets FROM ai_connections WHERE secrets IS NOT NULL').all() as {
+ id: number;
+ secrets: string;
+ }[];
+ for (const row of aiConnections) {
+ const newVal = migrateApiKeyValue(row.secrets, `ai_connections[${row.id}].secrets`);
+ if (newVal !== null) {
+ db.prepare('UPDATE ai_connections SET secrets = ? WHERE id = ?').run(newVal, row.id);
+ }
+ }
+ }
+
// --- trip_document_links: webhook_secret ---
// The secret a provider signs its webhook calls with, stored through the
// same blob helper. It is written once when the link is created and never
diff --git a/server/scripts/repository-owners.json b/server/scripts/repository-owners.json
index ff54734ee..d4b826ee9 100644
--- a/server/scripts/repository-owners.json
+++ b/server/scripts/repository-owners.json
@@ -1,7 +1,12 @@
{
"Addons": "addons",
+ "AiConnections": "ai-connections",
"AppSettings": "settings",
"AssignmentParticipants": "assignments",
+ "AssistantMessages": "assistant",
+ "AssistantThreads": "assistant",
+ "AssistantToolCalls": "assistant",
+ "AssistantUsage": "ai-connections",
"AuditLog": "audit",
"BucketList": "atlas",
"BudgetCategoryOrder": "budget",
diff --git a/server/src/addons.ts b/server/src/addons.ts
index 1d874167f..c4c45da04 100644
--- a/server/src/addons.ts
+++ b/server/src/addons.ts
@@ -13,6 +13,7 @@ export const ADDON_IDS = {
COLLECTIONS: 'collections',
ROADTRIP: 'roadtrip',
TOURS: 'tours',
+ AI_ASSISTANT: 'ai_assistant',
} as const;
export type AddonId = (typeof ADDON_IDS)[keyof typeof ADDON_IDS];
diff --git a/server/src/db/entities/AiConnections.entity.ts b/server/src/db/entities/AiConnections.entity.ts
new file mode 100644
index 000000000..85836ea95
--- /dev/null
+++ b/server/src/db/entities/AiConnections.entity.ts
@@ -0,0 +1,55 @@
+import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
+import { AiConnectionsRepository } from '../repositories/AiConnections.repository';
+import { DbTimestampType } from '../types';
+import { Users } from './Users.entity';
+
+export class AiConnections {
+ [EntityRepositoryType]?: AiConnectionsRepository;
+ id!: number & Opt;
+ scope!: string;
+ user?: Ref | null;
+ user_id?: number | null;
+ kind!: string;
+ model!: string;
+ base_url?: string | null;
+ secrets?: string | null;
+ secrets_version: number & Opt = 0;
+ hint?: string | null;
+ account?: string | null;
+ status: string & Opt = 'ok';
+ created_at!: string & Opt;
+ updated_at!: string & Opt;
+}
+
+export const AiConnectionsSchema = defineEntity({
+ class: AiConnections,
+ repository: () => AiConnectionsRepository,
+ uniques: [
+ {
+ name: 'idx_ai_connections_instance',
+ where: 'user_id IS NULL',
+ properties: ['scope'],
+ },
+ {
+ name: 'idx_ai_connections_user',
+ where: 'user_id IS NOT NULL',
+ properties: ['user_id'],
+ },
+ ],
+ properties: {
+ id: p.integer().primary(),
+ scope: p.text(),
+ user: () => p.manyToOne(Users).ref().deleteRule('cascade').nullable().hidden(),
+ user_id: p.integer().nullable().persist(false),
+ kind: p.text(),
+ model: p.text(),
+ base_url: p.text().nullable(),
+ secrets: p.text().nullable(),
+ secrets_version: p.integer().default(0),
+ hint: p.text().nullable(),
+ account: p.text().nullable(),
+ status: p.text().default('ok'),
+ created_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
+ updated_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
+ },
+});
diff --git a/server/src/db/entities/AssistantMessages.entity.ts b/server/src/db/entities/AssistantMessages.entity.ts
new file mode 100644
index 000000000..c46e511e9
--- /dev/null
+++ b/server/src/db/entities/AssistantMessages.entity.ts
@@ -0,0 +1,37 @@
+import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
+import { AssistantMessagesRepository } from '../repositories/AssistantMessages.repository';
+import { DbTimestampType } from '../types';
+import { AssistantThreads } from './AssistantThreads.entity';
+import { AssistantToolCalls } from './AssistantToolCalls.entity';
+
+export class AssistantMessages {
+ [EntityRepositoryType]?: AssistantMessagesRepository;
+ id!: number & Opt;
+ thread!: Ref;
+ thread_id!: number;
+ role!: string;
+ parts!: string;
+ provider_state?: string | null;
+ status: string & Opt = 'complete';
+ model?: string | null;
+ created_at!: string & Opt;
+ assistant_tool_calls_collection = new Collection(this);
+}
+
+export const AssistantMessagesSchema = defineEntity({
+ class: AssistantMessages,
+ repository: () => AssistantMessagesRepository,
+ indexes: [{ name: 'idx_assistant_messages_thread', properties: ['thread_id', 'id'] }],
+ properties: {
+ id: p.integer().primary(),
+ thread: () => p.manyToOne(AssistantThreads).ref().deleteRule('cascade').hidden(),
+ thread_id: p.integer().persist(false),
+ role: p.text(),
+ parts: p.text(),
+ provider_state: p.text().nullable(),
+ status: p.text().default('complete'),
+ model: p.text().nullable(),
+ created_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
+ assistant_tool_calls_collection: () => p.oneToMany(AssistantToolCalls).mappedBy('message').hidden(),
+ },
+});
diff --git a/server/src/db/entities/AssistantThreads.entity.ts b/server/src/db/entities/AssistantThreads.entity.ts
new file mode 100644
index 000000000..0171734ac
--- /dev/null
+++ b/server/src/db/entities/AssistantThreads.entity.ts
@@ -0,0 +1,44 @@
+import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
+import { AssistantThreadsRepository } from '../repositories/AssistantThreads.repository';
+import { DbTimestampType } from '../types';
+import { AssistantMessages } from './AssistantMessages.entity';
+import { AssistantToolCalls } from './AssistantToolCalls.entity';
+import { Trips } from './Trips.entity';
+import { Users } from './Users.entity';
+
+export class AssistantThreads {
+ [EntityRepositoryType]?: AssistantThreadsRepository;
+ id!: number & Opt;
+ user!: Ref;
+ user_id!: number;
+ trip!: Ref;
+ trip_id!: number;
+ title!: string;
+ created_at!: string & Opt;
+ last_message_at!: string & Opt;
+ assistant_messages_collection = new Collection(this);
+ assistant_tool_calls_collection = new Collection(this);
+}
+
+export const AssistantThreadsSchema = defineEntity({
+ class: AssistantThreads,
+ repository: () => AssistantThreadsRepository,
+ indexes: [
+ {
+ name: 'idx_assistant_threads_user_trip',
+ properties: ['user_id', 'trip_id', 'last_message_at'],
+ },
+ ],
+ properties: {
+ id: p.integer().primary(),
+ user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(),
+ user_id: p.integer().persist(false),
+ trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_assistant_threads_trip'),
+ trip_id: p.integer().persist(false).index('idx_assistant_threads_trip'),
+ title: p.text(),
+ created_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
+ last_message_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
+ assistant_messages_collection: () => p.oneToMany(AssistantMessages).mappedBy('thread').hidden(),
+ assistant_tool_calls_collection: () => p.oneToMany(AssistantToolCalls).mappedBy('thread').hidden(),
+ },
+});
diff --git a/server/src/db/entities/AssistantToolCalls.entity.ts b/server/src/db/entities/AssistantToolCalls.entity.ts
new file mode 100644
index 000000000..84d5a39e7
--- /dev/null
+++ b/server/src/db/entities/AssistantToolCalls.entity.ts
@@ -0,0 +1,46 @@
+import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
+import { AssistantToolCallsRepository } from '../repositories/AssistantToolCalls.repository';
+import { DbTimestampType } from '../types';
+import { AssistantMessages } from './AssistantMessages.entity';
+import { AssistantThreads } from './AssistantThreads.entity';
+
+export class AssistantToolCalls {
+ [EntityRepositoryType]?: AssistantToolCallsRepository;
+ id!: string;
+ thread!: Ref;
+ thread_id!: number;
+ message!: Ref;
+ message_id!: number;
+ provider_call_id!: string;
+ tool_name!: string;
+ args!: string;
+ preview!: string;
+ state: string & Opt = 'approval_requested';
+ result?: string | null;
+ undo_until?: string | null;
+ created_at!: string & Opt;
+ decided_at?: string | null;
+}
+
+export const AssistantToolCallsSchema = defineEntity({
+ class: AssistantToolCalls,
+ repository: () => AssistantToolCallsRepository,
+ uniques: [{ properties: ['thread', 'provider_call_id'] }],
+ properties: {
+ id: p.text().primary(),
+ thread: () => p.manyToOne(AssistantThreads).ref().deleteRule('cascade').hidden(),
+ thread_id: p.integer().persist(false),
+ message: () =>
+ p.manyToOne(AssistantMessages).ref().deleteRule('cascade').hidden().index('idx_assistant_tool_calls_message'),
+ message_id: p.integer().persist(false).index('idx_assistant_tool_calls_message'),
+ provider_call_id: p.text(),
+ tool_name: p.text(),
+ args: p.text(),
+ preview: p.text(),
+ state: p.text().default('approval_requested'),
+ result: p.text().nullable(),
+ undo_until: p.type(DbTimestampType).nullable(),
+ created_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
+ decided_at: p.type(DbTimestampType).nullable(),
+ },
+});
diff --git a/server/src/db/entities/AssistantUsage.entity.ts b/server/src/db/entities/AssistantUsage.entity.ts
new file mode 100644
index 000000000..f72c498d9
--- /dev/null
+++ b/server/src/db/entities/AssistantUsage.entity.ts
@@ -0,0 +1,30 @@
+import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
+import { AssistantUsageRepository } from '../repositories/AssistantUsage.repository';
+import { Users } from './Users.entity';
+
+export class AssistantUsage {
+ [EntityRepositoryType]?: AssistantUsageRepository;
+ [PrimaryKeyProp]?: ['user', 'day', 'paid_by'];
+ user!: Ref;
+ user_id!: number;
+ day!: string;
+ paid_by!: string;
+ turns: number & Opt = 0;
+ input_tokens: number & Opt = 0;
+ output_tokens: number & Opt = 0;
+}
+
+export const AssistantUsageSchema = defineEntity({
+ class: AssistantUsage,
+ repository: () => AssistantUsageRepository,
+ uniques: [{ properties: ['user', 'day', 'paid_by'] }],
+ properties: {
+ user: () => p.manyToOne(Users).primary().ref().deleteRule('cascade').hidden(),
+ user_id: p.integer().persist(false),
+ day: p.text().primary(),
+ paid_by: p.text().primary(),
+ turns: p.integer().default(0),
+ input_tokens: p.integer().default(0),
+ output_tokens: p.integer().default(0),
+ },
+});
diff --git a/server/src/db/entities/CollabMessages.entity.ts b/server/src/db/entities/CollabMessages.entity.ts
index 8774c8174..fb14076d1 100644
--- a/server/src/db/entities/CollabMessages.entity.ts
+++ b/server/src/db/entities/CollabMessages.entity.ts
@@ -17,6 +17,7 @@ export class CollabMessages {
reply_to?: number | null;
created_at?: string | null;
deleted?: number | null = 0;
+ origin?: string | null;
replyToRef?: Ref | null;
collab_message_reactions_collection = new Collection(this);
collab_messages_collection = new Collection(this);
@@ -36,6 +37,7 @@ export const CollabMessagesSchema = defineEntity({
reply_to: p.integer().nullable().persist(false).index('idx_collab_messages_reply_to'),
created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
deleted: p.integer().nullable(),
+ origin: p.text().nullable(),
replyToRef: () => p.manyToOne(CollabMessages).ref().joinColumn('reply_to').nullable().hidden().index('idx_collab_messages_reply_to'),
collab_message_reactions_collection: () => p.oneToMany(CollabMessageReactions).mappedBy('message').hidden(),
collab_messages_collection: () => p.oneToMany(CollabMessages).mappedBy('replyToRef').hidden(),
diff --git a/server/src/db/entities/Trips.entity.ts b/server/src/db/entities/Trips.entity.ts
index 681e6de48..62ed4756b 100644
--- a/server/src/db/entities/Trips.entity.ts
+++ b/server/src/db/entities/Trips.entity.ts
@@ -1,6 +1,7 @@
import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TripsRepository } from '../repositories/Trips.repository';
import { DbTimestampType } from '../types';
+import { AssistantThreads } from './AssistantThreads.entity';
import { BudgetCategoryOrder } from './BudgetCategoryOrder.entity';
import { BudgetItems } from './BudgetItems.entity';
import { BudgetSettlements } from './BudgetSettlements.entity';
@@ -54,6 +55,7 @@ export class Trips {
created_at?: string | null;
updated_at?: string | null;
reminder_sent_for?: string | null;
+ assistant_threads_collection = new Collection(this);
budget_category_order_collection = new Collection(this);
budget_items_collection = new Collection(this);
budget_settlements_collection = new Collection(this);
@@ -116,6 +118,7 @@ export const TripsSchema = defineEntity({
created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`).index('idx_trips_created_at'),
updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
reminder_sent_for: p.text().nullable(),
+ assistant_threads_collection: () => p.oneToMany(AssistantThreads).mappedBy('trip').hidden(),
budget_category_order_collection: () => p.oneToMany(BudgetCategoryOrder).mappedBy('trip').hidden(),
budget_items_collection: () => p.oneToMany(BudgetItems).mappedBy('trip').hidden(),
budget_settlements_collection: () => p.oneToMany(BudgetSettlements).mappedBy('trip').hidden(),
diff --git a/server/src/db/entities/Users.entity.ts b/server/src/db/entities/Users.entity.ts
index aed467eed..4d7eec9f0 100644
--- a/server/src/db/entities/Users.entity.ts
+++ b/server/src/db/entities/Users.entity.ts
@@ -1,7 +1,10 @@
import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
import { UsersRepository } from '../repositories/Users.repository';
import { DbTimestampType } from '../types';
+import { AiConnections } from './AiConnections.entity';
import { AssignmentParticipants } from './AssignmentParticipants.entity';
+import { AssistantThreads } from './AssistantThreads.entity';
+import { AssistantUsage } from './AssistantUsage.entity';
import { AuditLog } from './AuditLog.entity';
import { BucketList } from './BucketList.entity';
import { BudgetItemMembers } from './BudgetItemMembers.entity';
@@ -128,7 +131,10 @@ export class Users {
display_name?: string | null;
immich_allow_insecure_tls: number & Opt = 0;
email_self_asserted: number & Opt = 0;
+ ai_connections_collection = new Collection(this);
assignment_participants_collection = new Collection(this);
+ assistant_threads_collection = new Collection(this);
+ assistant_usage_collection = new Collection(this);
audit_log_collection = new Collection(this);
bucket_list_collection = new Collection(this);
budget_item_members_collection = new Collection(this);
@@ -281,7 +287,10 @@ export const UsersSchema = defineEntity({
display_name: p.text().nullable(),
immich_allow_insecure_tls: p.integer().default(0),
email_self_asserted: p.integer().default(0),
+ ai_connections_collection: () => p.oneToMany(AiConnections).mappedBy('user').hidden(),
assignment_participants_collection: () => p.oneToMany(AssignmentParticipants).mappedBy('user').hidden(),
+ assistant_threads_collection: () => p.oneToMany(AssistantThreads).mappedBy('user').hidden(),
+ assistant_usage_collection: () => p.oneToMany(AssistantUsage).mappedBy('user').hidden(),
audit_log_collection: () => p.oneToMany(AuditLog).mappedBy('user').hidden(),
bucket_list_collection: () => p.oneToMany(BucketList).mappedBy('user').hidden(),
budget_item_members_collection: () => p.oneToMany(BudgetItemMembers).mappedBy('user').hidden(),
diff --git a/server/src/db/entities/index.ts b/server/src/db/entities/index.ts
index ce5300b3b..f7f56a0e9 100644
--- a/server/src/db/entities/index.ts
+++ b/server/src/db/entities/index.ts
@@ -1,7 +1,12 @@
import type { EntitySchema } from '@mikro-orm/core';
import { AddonsSchema } from './Addons.entity';
+import { AiConnectionsSchema } from './AiConnections.entity';
import { AppSettingsSchema } from './AppSettings.entity';
import { AssignmentParticipantsSchema } from './AssignmentParticipants.entity';
+import { AssistantMessagesSchema } from './AssistantMessages.entity';
+import { AssistantThreadsSchema } from './AssistantThreads.entity';
+import { AssistantToolCallsSchema } from './AssistantToolCalls.entity';
+import { AssistantUsageSchema } from './AssistantUsage.entity';
import { AuditLogSchema } from './AuditLog.entity';
import { BucketListSchema } from './BucketList.entity';
import { BudgetCategoryOrderSchema } from './BudgetCategoryOrder.entity';
@@ -154,8 +159,13 @@ import { WebauthnCredentialsSchema } from './WebauthnCredentials.entity';
export const ALL_ENTITIES: readonly EntitySchema[] = [
AddonsSchema,
+ AiConnectionsSchema,
AppSettingsSchema,
AssignmentParticipantsSchema,
+ AssistantMessagesSchema,
+ AssistantThreadsSchema,
+ AssistantToolCallsSchema,
+ AssistantUsageSchema,
AuditLogSchema,
BucketListSchema,
BudgetCategoryOrderSchema,
diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts
index e2bfd4bd8..f152f5fc8 100644
--- a/server/src/db/json-columns.ts
+++ b/server/src/db/json-columns.ts
@@ -2,6 +2,8 @@ import type { JsonColumn } from '../utils/json-column';
import {
FERRATAS_RESULT_CAP,
dawarichTrackSegmentSchema,
+ assistantChangePreviewSchema,
+ assistantMessagePartSchema,
ferrataSchema,
hikingPreferencesSchema,
obstacleSnapshotSchema,
@@ -17,6 +19,8 @@ import {
tourWaypointSchema,
trackMarksSchema,
type DawarichTrackSegment,
+ type AssistantChangePreview,
+ type AssistantMessagePart,
type Ferrata,
type GeometryPoint,
type HikingPreferences,
@@ -328,3 +332,70 @@ export const JOURNEY_RECORDED_DAY_SEGMENTS: JsonColumn =
schema: z.array(dawarichTrackSegmentSchema),
fallback: () => [],
};
+ * The parts of an assistant message as the client renders them: text, tool
+ * calls with their results and approvals, change previews and receipts. A
+ * broken row reads as an empty message and is logged.
+ */
+export const ASSISTANT_MESSAGE_PARTS: JsonColumn = {
+ column: 'assistant_messages.parts',
+ schema: z.array(assistantMessagePartSchema),
+ fallback: () => [],
+};
+
+/**
+ * What a provider needs replayed as it sent it, when its history cannot be
+ * rebuilt from the parts: the Responses API's output items (reasoning with its
+ * encrypted content, function calls) for a conversation that stores nothing on
+ * the provider's side. A broken row reads as none, and the history is rebuilt
+ * from the parts instead.
+ */
+const aiProviderStateSchema = z.object({
+ adapter: z.literal('openai-responses'),
+ items: z.array(z.record(z.string(), z.unknown())),
+});
+export type AiProviderState = z.infer;
+
+export const ASSISTANT_PROVIDER_STATE: JsonColumn = {
+ column: 'assistant_messages.provider_state',
+ schema: aiProviderStateSchema.nullable(),
+ fallback: () => null,
+};
+
+/** The arguments a proposed change runs with once approved, as the model sent them. A broken row runs nothing. */
+export const ASSISTANT_TOOL_ARGS: JsonColumn> = {
+ column: 'assistant_tool_calls.args',
+ schema: z.record(z.string(), z.unknown()),
+ fallback: () => ({}),
+};
+
+/** The change card of a proposed change. A broken row reads as none. */
+export const ASSISTANT_TOOL_PREVIEW: JsonColumn = {
+ column: 'assistant_tool_calls.preview',
+ schema: assistantChangePreviewSchema.nullable(),
+ fallback: () => null,
+};
+
+/** What an executed change answered, the tool's own result. A broken row reads as none, which offers no undo. */
+export const ASSISTANT_TOOL_RESULT: JsonColumn = {
+ column: 'assistant_tool_calls.result',
+ schema: z.unknown(),
+ fallback: () => null,
+};
+
+/**
+ * What a ChatGPT sign-in leaves that is not secret: the client id issued to
+ * this user's registration, the account's subject and the scopes granted. A
+ * broken row reads as none, so the next sign-in registers afresh.
+ */
+const aiConnectionAccountSchema = z.object({
+ clientId: z.string().optional(),
+ sub: z.string().optional(),
+ scopes: z.array(z.string()).optional(),
+});
+export type AiConnectionAccount = z.infer;
+
+export const AI_CONNECTION_ACCOUNT: JsonColumn = {
+ column: 'ai_connections.account',
+ schema: aiConnectionAccountSchema,
+ fallback: () => ({}),
+};
diff --git a/server/src/db/kysely/AiConnections.table.ts b/server/src/db/kysely/AiConnections.table.ts
new file mode 100644
index 000000000..3b800f2d6
--- /dev/null
+++ b/server/src/db/kysely/AiConnections.table.ts
@@ -0,0 +1,19 @@
+// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
+// change the migration, then run `npm run gen:db-types`.
+import type { InsertOptional } from './columns';
+
+export interface AiConnectionsTable {
+ id: InsertOptional;
+ scope: string;
+ user_id: InsertOptional;
+ kind: string;
+ model: string;
+ base_url: InsertOptional;
+ secrets: InsertOptional;
+ secrets_version: InsertOptional;
+ hint: InsertOptional;
+ account: InsertOptional;
+ status: InsertOptional;
+ created_at: InsertOptional;
+ updated_at: InsertOptional;
+}
diff --git a/server/src/db/kysely/AssistantMessages.table.ts b/server/src/db/kysely/AssistantMessages.table.ts
new file mode 100644
index 000000000..f18ec0122
--- /dev/null
+++ b/server/src/db/kysely/AssistantMessages.table.ts
@@ -0,0 +1,14 @@
+// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
+// change the migration, then run `npm run gen:db-types`.
+import type { InsertOptional } from './columns';
+
+export interface AssistantMessagesTable {
+ id: InsertOptional;
+ thread_id: number;
+ role: string;
+ parts: string;
+ provider_state: InsertOptional;
+ status: InsertOptional;
+ model: InsertOptional;
+ created_at: InsertOptional;
+}
diff --git a/server/src/db/kysely/AssistantThreads.table.ts b/server/src/db/kysely/AssistantThreads.table.ts
new file mode 100644
index 000000000..3ee20fa4c
--- /dev/null
+++ b/server/src/db/kysely/AssistantThreads.table.ts
@@ -0,0 +1,12 @@
+// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
+// change the migration, then run `npm run gen:db-types`.
+import type { InsertOptional } from './columns';
+
+export interface AssistantThreadsTable {
+ id: InsertOptional;
+ user_id: number;
+ trip_id: number;
+ title: string;
+ created_at: InsertOptional;
+ last_message_at: InsertOptional;
+}
diff --git a/server/src/db/kysely/AssistantToolCalls.table.ts b/server/src/db/kysely/AssistantToolCalls.table.ts
new file mode 100644
index 000000000..e02680eca
--- /dev/null
+++ b/server/src/db/kysely/AssistantToolCalls.table.ts
@@ -0,0 +1,18 @@
+// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
+// change the migration, then run `npm run gen:db-types`.
+import type { InsertOptional } from './columns';
+
+export interface AssistantToolCallsTable {
+ id: string;
+ thread_id: number;
+ message_id: number;
+ provider_call_id: string;
+ tool_name: string;
+ args: string;
+ preview: string;
+ state: InsertOptional;
+ result: InsertOptional;
+ undo_until: InsertOptional;
+ created_at: InsertOptional;
+ decided_at: InsertOptional;
+}
diff --git a/server/src/db/kysely/AssistantUsage.table.ts b/server/src/db/kysely/AssistantUsage.table.ts
new file mode 100644
index 000000000..7186713ec
--- /dev/null
+++ b/server/src/db/kysely/AssistantUsage.table.ts
@@ -0,0 +1,12 @@
+// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
+// change the migration, then run `npm run gen:db-types`.
+import type { InsertOptional } from './columns';
+
+export interface AssistantUsageTable {
+ user_id: number;
+ day: string;
+ paid_by: string;
+ turns: InsertOptional;
+ input_tokens: InsertOptional;
+ output_tokens: InsertOptional;
+}
diff --git a/server/src/db/kysely/CollabMessages.table.ts b/server/src/db/kysely/CollabMessages.table.ts
index 8c0e462c6..beaf2ff4f 100644
--- a/server/src/db/kysely/CollabMessages.table.ts
+++ b/server/src/db/kysely/CollabMessages.table.ts
@@ -10,4 +10,5 @@ export interface CollabMessagesTable {
reply_to: InsertOptional;
created_at: InsertOptional;
deleted: InsertOptional;
+ origin: InsertOptional;
}
diff --git a/server/src/db/kysely/db.ts b/server/src/db/kysely/db.ts
index e6b5a3b68..3f5dc1764 100644
--- a/server/src/db/kysely/db.ts
+++ b/server/src/db/kysely/db.ts
@@ -1,8 +1,13 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { AddonsTable } from './Addons.table';
+import type { AiConnectionsTable } from './AiConnections.table';
import type { AppSettingsTable } from './AppSettings.table';
import type { AssignmentParticipantsTable } from './AssignmentParticipants.table';
+import type { AssistantMessagesTable } from './AssistantMessages.table';
+import type { AssistantThreadsTable } from './AssistantThreads.table';
+import type { AssistantToolCallsTable } from './AssistantToolCalls.table';
+import type { AssistantUsageTable } from './AssistantUsage.table';
import type { AuditLogTable } from './AuditLog.table';
import type { BucketListTable } from './BucketList.table';
import type { BudgetCategoryOrderTable } from './BudgetCategoryOrder.table';
@@ -161,8 +166,13 @@ import type { WebauthnCredentialsTable } from './WebauthnCredentials.table';
/** Every table of the migrated schema. A repository narrows it: `this.kysely>()`. */
export interface DB {
addons: AddonsTable;
+ ai_connections: AiConnectionsTable;
app_settings: AppSettingsTable;
assignment_participants: AssignmentParticipantsTable;
+ assistant_messages: AssistantMessagesTable;
+ assistant_threads: AssistantThreadsTable;
+ assistant_tool_calls: AssistantToolCallsTable;
+ assistant_usage: AssistantUsageTable;
audit_log: AuditLogTable;
bucket_list: BucketListTable;
budget_category_order: BudgetCategoryOrderTable;
diff --git a/server/src/db/migrations/Migration20200101046200_a_user_can_connect_an_ai_provider.ts b/server/src/db/migrations/Migration20200101046200_a_user_can_connect_an_ai_provider.ts
new file mode 100644
index 000000000..5b2875e7d
--- /dev/null
+++ b/server/src/db/migrations/Migration20200101046200_a_user_can_connect_an_ai_provider.ts
@@ -0,0 +1,52 @@
+import { Migration } from '@mikro-orm/migrations';
+
+/**
+ * The connections the trip assistant talks to a language model through: at most
+ * one the admin sets up for everyone (`scope = 'instance'`, no user) and at most
+ * one per user (`scope = 'user'`).
+ *
+ * `secrets` is one encrypted JSON blob (an API key, or ChatGPT's access and
+ * refresh tokens), rotated as a whole by scripts/migrate-encryption.ts, and
+ * `secrets_version` counts its writes so a token refresh can replace it only if
+ * nobody else did in between. `account` holds what a sign-in leaves that is not
+ * secret (ChatGPT's issued client id, the account's subject and scopes), so a
+ * signed-out connection can sign in again as the same registration. `hint` is
+ * what the settings show instead of the secret.
+ *
+ * Two kinds are bound to one side: a ChatGPT plan is personal and never the
+ * instance connection, and a custom base URL (an Ollama in the LAN, say) is
+ * only ever the admin's (#1772). The checks refuse the other combinations.
+ */
+export class Migration20200101046200_a_user_can_connect_an_ai_provider extends Migration {
+ override name = 'Migration20200101046200_a_user_can_connect_an_ai_provider';
+
+ override up(): void {
+ this.addSql(`
+ CREATE TABLE IF NOT EXISTS ai_connections (
+ id INTEGER PRIMARY KEY,
+ scope TEXT NOT NULL CHECK(scope IN ('instance', 'user')),
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ kind TEXT NOT NULL CHECK(kind IN ('anthropic', 'openai', 'gemini', 'openrouter', 'chatgpt', 'custom')),
+ model TEXT NOT NULL,
+ base_url TEXT,
+ secrets TEXT,
+ secrets_version INTEGER NOT NULL DEFAULT 0,
+ hint TEXT,
+ account TEXT,
+ status TEXT NOT NULL DEFAULT 'ok' CHECK(status IN ('ok', 'needs_reauth', 'signed_out', 'error')),
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CHECK((scope = 'user') = (user_id IS NOT NULL)),
+ CHECK(kind <> 'chatgpt' OR scope = 'user'),
+ CHECK(kind <> 'custom' OR (scope = 'instance' AND base_url IS NOT NULL)),
+ CHECK(base_url IS NULL OR kind = 'custom')
+ )
+ `);
+ this.addSql(
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_connections_user ON ai_connections(user_id) WHERE user_id IS NOT NULL',
+ );
+ this.addSql(
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_connections_instance ON ai_connections(scope) WHERE user_id IS NULL',
+ );
+ }
+}
diff --git a/server/src/db/migrations/Migration20200101046210_the_trip_assistant_remembers_its_conversations.ts b/server/src/db/migrations/Migration20200101046210_the_trip_assistant_remembers_its_conversations.ts
new file mode 100644
index 000000000..a8d04fab3
--- /dev/null
+++ b/server/src/db/migrations/Migration20200101046210_the_trip_assistant_remembers_its_conversations.ts
@@ -0,0 +1,86 @@
+import { Migration } from '@mikro-orm/migrations';
+
+/**
+ * The trip assistant's memory: one user's conversations about one trip.
+ *
+ * `assistant_threads` belong to a user and a trip and go with either.
+ * `assistant_messages` keep each message's parts as the client renders them
+ * (`parts`, JSON) and, for providers whose history has to be replayed as they
+ * sent it, the provider's own items (`provider_state`, JSON).
+ *
+ * `assistant_tool_calls` are the changes the assistant proposed and the user
+ * decides on. The key is our own id, not the provider's: a provider's call id
+ * is only unique within its conversation (`UNIQUE(thread_id, provider_call_id)`).
+ * The arguments are stored when the change is proposed and are what runs on
+ * approval, never what a client sends back; `undo_until` is how long the
+ * counterpart of an executed change may still be run.
+ *
+ * `assistant_usage` counts turns and tokens per user, UTC day and payer, which
+ * is what the daily limit on the instance's connection is checked against.
+ */
+export class Migration20200101046210_the_trip_assistant_remembers_its_conversations extends Migration {
+ override name = 'Migration20200101046210_the_trip_assistant_remembers_its_conversations';
+
+ override up(): void {
+ this.addSql(`
+ CREATE TABLE IF NOT EXISTS assistant_threads (
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_message_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ this.addSql(
+ 'CREATE INDEX IF NOT EXISTS idx_assistant_threads_user_trip ON assistant_threads(user_id, trip_id, last_message_at)',
+ );
+ this.addSql('CREATE INDEX IF NOT EXISTS idx_assistant_threads_trip ON assistant_threads(trip_id)');
+
+ this.addSql(`
+ CREATE TABLE IF NOT EXISTS assistant_messages (
+ id INTEGER PRIMARY KEY,
+ thread_id INTEGER NOT NULL REFERENCES assistant_threads(id) ON DELETE CASCADE,
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
+ parts TEXT NOT NULL,
+ provider_state TEXT,
+ status TEXT NOT NULL DEFAULT 'complete' CHECK(status IN ('complete', 'awaiting_approval', 'aborted', 'error')),
+ model TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ this.addSql('CREATE INDEX IF NOT EXISTS idx_assistant_messages_thread ON assistant_messages(thread_id, id)');
+
+ this.addSql(`
+ CREATE TABLE IF NOT EXISTS assistant_tool_calls (
+ id TEXT PRIMARY KEY NOT NULL,
+ thread_id INTEGER NOT NULL REFERENCES assistant_threads(id) ON DELETE CASCADE,
+ message_id INTEGER NOT NULL REFERENCES assistant_messages(id) ON DELETE CASCADE,
+ provider_call_id TEXT NOT NULL,
+ tool_name TEXT NOT NULL,
+ args TEXT NOT NULL,
+ preview TEXT NOT NULL,
+ state TEXT NOT NULL DEFAULT 'approval_requested'
+ CHECK(state IN ('approval_requested', 'approved', 'rejected', 'executed', 'failed', 'expired', 'undone')),
+ result TEXT,
+ undo_until DATETIME,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ decided_at DATETIME,
+ UNIQUE(thread_id, provider_call_id)
+ )
+ `);
+ this.addSql('CREATE INDEX IF NOT EXISTS idx_assistant_tool_calls_message ON assistant_tool_calls(message_id)');
+
+ this.addSql(`
+ CREATE TABLE IF NOT EXISTS assistant_usage (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ day TEXT NOT NULL,
+ paid_by TEXT NOT NULL CHECK(paid_by IN ('instance', 'user')),
+ turns INTEGER NOT NULL DEFAULT 0,
+ input_tokens INTEGER NOT NULL DEFAULT 0,
+ output_tokens INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (user_id, day, paid_by)
+ )
+ `);
+ }
+}
diff --git a/server/src/db/migrations/Migration20200101046220_a_chat_message_can_say_it_came_from_the_assistant.ts b/server/src/db/migrations/Migration20200101046220_a_chat_message_can_say_it_came_from_the_assistant.ts
new file mode 100644
index 000000000..add4c0cc4
--- /dev/null
+++ b/server/src/db/migrations/Migration20200101046220_a_chat_message_can_say_it_came_from_the_assistant.ts
@@ -0,0 +1,16 @@
+import { Migration } from '@mikro-orm/migrations';
+
+/**
+ * A trip chat message can say where it came from when nobody typed it in the
+ * chat: `'assistant'` for an answer of the trip assistant a member shared,
+ * which the chat marks as such in each reader's language. NULL, as on every
+ * message before, is one typed in the chat. Only the assistant's share sets
+ * it; the chat's own routes and tools never do.
+ */
+export class Migration20200101046220_a_chat_message_can_say_it_came_from_the_assistant extends Migration {
+ override name = 'Migration20200101046220_a_chat_message_can_say_it_came_from_the_assistant';
+
+ override up(): void {
+ this.addSql(`ALTER TABLE collab_messages ADD COLUMN origin TEXT`);
+ }
+}
diff --git a/server/src/db/repositories/AiConnections.repository.ts b/server/src/db/repositories/AiConnections.repository.ts
new file mode 100644
index 000000000..4046bcb93
--- /dev/null
+++ b/server/src/db/repositories/AiConnections.repository.ts
@@ -0,0 +1,178 @@
+import { currentTimestampKysely } from '../dialect/kysely-functions';
+import type { AiConnections } from '../entities/AiConnections.entity';
+import type { DB } from '../kysely/db';
+import type { AssertRowKeys } from './_shared/rows';
+import { TrekRepository } from './_shared/trek-repository';
+
+/**
+ * One `ai_connections` row as stored. `secrets` is the still-encrypted blob and
+ * `account` the JSON text of AI_CONNECTION_ACCOUNT; the service opens both.
+ */
+export interface AiConnectionRow {
+ id: number;
+ scope: string;
+ user_id: number | null;
+ kind: string;
+ model: string;
+ base_url: string | null;
+ secrets: string | null;
+ secrets_version: number;
+ hint: string | null;
+ account: string | null;
+ status: string;
+ created_at: string;
+ updated_at: string;
+}
+
+const _aiConnectionRowKeys: AssertRowKeys = true;
+
+/** What a save writes; the row's scope and owner are fixed by the call. */
+interface AiConnectionWrite {
+ kind: string;
+ model: string;
+ baseUrl: string | null;
+ secrets: string | null;
+ hint: string | null;
+ account: string | null;
+}
+
+type AiConnectionsKyselyDB = Pick;
+
+const COLUMNS = [
+ 'id',
+ 'scope',
+ 'user_id',
+ 'kind',
+ 'model',
+ 'base_url',
+ 'secrets',
+ 'secrets_version',
+ 'hint',
+ 'account',
+ 'status',
+ 'created_at',
+ 'updated_at',
+] as const;
+
+/**
+ * `ai_connections`: the instance's connection (no user) and one per user. A
+ * save replaces the row of its owner, so the partial unique indexes never see
+ * a second one; every write of `secrets` counts up `secrets_version`, which is
+ * what a token refresh compares against before it writes.
+ */
+export class AiConnectionsRepository extends TrekRepository