Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
396 changes: 296 additions & 100 deletions apps/dashboard/src/main/mcp-server.ts

Large diffs are not rendered by default.

13 changes: 5 additions & 8 deletions apps/dashboard/src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type {
McpClientId,
McpConnectionInfo,
McpToolPhase,
NavStatePayload,
NavTabInfo,
OverlayLine,
OverlayPoint,
OverlayPolygon,
Expand All @@ -35,8 +37,6 @@ type ViewportState = {
zoom: number;
};

type NavTabInfo = { path: string; kind: "place" | "folder"; title: string };

declare global {
interface Window {
electron: ElectronAPI;
Expand Down Expand Up @@ -66,21 +66,18 @@ declare global {
};
map: {
onOverlayAdd: (cb: (layer: MapOverlayLayer) => void) => void;
onOverlayUpdate: (cb: (layer: MapOverlayLayer) => void) => void;
sendViewport: (data: ViewportState) => void;
onPanTo: (cb: (data: { lat: number; lng: number; zoom?: number }) => void) => void;
removeListeners: () => void;
removeOverlayListeners: () => void;
};
nav: {
sendNavState: (data: {
active: NavTabInfo | null;
activeIndex: number;
tabs: NavTabInfo[];
}) => void;
sendNavState: (data: NavStatePayload) => void;
onOpenFile: (cb: (data: { path: string }) => void) => void;
onOpenDirections: (
cb: (data: {
stops: ({ lat: number; lng: number; label: string } | null)[];
stops: ({ lat: number; lng: number; label: string; filePath?: string } | null)[];
mode: "auto" | "pedestrian" | "bicycle";
}) => void
) => void;
Expand Down
12 changes: 6 additions & 6 deletions apps/dashboard/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
McpClientId,
McpConnectionInfo,
McpToolPhase,
NavStatePayload,
PropertyType,
RegionDownloadProgress,
RegionManifest
Expand Down Expand Up @@ -59,6 +60,8 @@ const api = {
map: {
onOverlayAdd: (cb: (layer: MapOverlayLayer) => void) =>
ipcRenderer.on("map:overlay-add", (_e, layer) => cb(layer)),
onOverlayUpdate: (cb: (layer: MapOverlayLayer) => void) =>
ipcRenderer.on("map:overlay-update", (_e, layer) => cb(layer)),
sendViewport: (data: {
north: number;
south: number;
Expand All @@ -77,22 +80,19 @@ const api = {
/** Overlay listener is owned by App; not cleared by MapView.removeListeners. */
removeOverlayListeners: () => {
ipcRenderer.removeAllListeners("map:overlay-add");
ipcRenderer.removeAllListeners("map:overlay-update");
}
},
nav: {
sendNavState: (data: {
active: { path: string; kind: "place" | "folder"; title: string } | null;
activeIndex: number;
tabs: Array<{ path: string; kind: "place" | "folder"; title: string }>;
}) => ipcRenderer.send("nav:state-update", data),
sendNavState: (data: NavStatePayload) => ipcRenderer.send("nav:state-update", data),
onOpenFile: (cb: (data: { path: string }) => void) =>
ipcRenderer.on("nav:open-file", (_e, data) => cb(data)),
/** Agent `present_directions`: open a Directions tab for the given ordered stops
* (stops[0] = origin, last = destination, any in between are waypoints). A null first
* stop → the renderer defaults to the user's current location. */
onOpenDirections: (
cb: (data: {
stops: ({ lat: number; lng: number; label: string } | null)[];
stops: ({ lat: number; lng: number; label: string; filePath?: string } | null)[];
mode: "auto" | "pedestrian" | "bicycle";
}) => void
) => ipcRenderer.on("nav:open-directions", (_e, data) => cb(data)),
Expand Down
61 changes: 48 additions & 13 deletions apps/dashboard/src/renderer/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@mapos/ui/components/to
import { cn } from "@mapos/ui/lib/utils";
import { detailPropertiesFromGeocodeResult } from "@shared/geocode-detail";
import { type RouteFrontmatter, type RouteStop, defaultRouteTitle } from "@shared/route";
import type { MapOverlayLayer, OverlayPoint, PlaceAppearance } from "@shared/types";
import { DIRECTIONS_OVERLAY_PREFIX, orderDetailProperties } from "@shared/types";
import type { MapOverlayLayer, NavTabInfo, OverlayPoint, PlaceAppearance } from "@shared/types";
import {
DIRECTIONS_OVERLAY_PREFIX,
featureListSummaryFromLayer,
orderDetailProperties
} from "@shared/types";
import { bbox } from "@turf/bbox";
import type { Geometry } from "geojson";
import { ChevronLeftIcon, ChevronRightIcon, PanelLeftIcon } from "lucide-react";
Expand Down Expand Up @@ -478,7 +482,26 @@ function App(): React.JSX.Element {
[dispatchNav, getMapPadding]
);

useMapOverlaySync({ addLayer: handleOverlayLayer });
/** Agent `present_features` with `layer_id`: replace an open list tab in place. */
const handleOverlayLayerUpdate = useCallback(
(layer: MapOverlayLayer) => {
dispatchNav({ type: "update-list", layerId: layer.id, layer });
setSelectedPlace(null);
setSelectedFolder(null);
setPlaceMode("mini");
setFeatureScreenPos(null);
setMapPeekPlace(null);
setSelectionPulseAnchor(null);
setFocusedFeatureId(null);
const frame = overlayFeatureCollection(layer);
if (frame.features.length > 0) {
mapRef.current?.fitToGeoJson(frame, getMapPadding(true));
}
},
[dispatchNav, getMapPadding]
);

useMapOverlaySync({ addLayer: handleOverlayLayer, updateLayer: handleOverlayLayerUpdate });

/** Current active tab entry — drives which pane (place / list / folder) is shown. */
const activeNavEntry = useMemo(() => {
Expand Down Expand Up @@ -1133,24 +1156,36 @@ function App(): React.JSX.Element {
/** Push the current tab/selection state to main so the agent's get_active_file /
* get_open_tabs tools can see what the user is looking at. Mirrors the viewport push. */
useEffect(() => {
const toInfo = (tab: (typeof nav.tabs)[number]) => {
const toRelative = (path: string) => {
if (!vaultRoot || !path.startsWith(vaultRoot)) return path;
const rel = path.slice(vaultRoot.length).replace(/^[/\\]/, "");
return rel.split(/[/\\]/).join("/");
};
const toInfo = (tab: (typeof nav.tabs)[number]): NavTabInfo | null => {
const cur = tab.history[tab.cursor];
if (cur.kind === "place")
return { path: cur.place.filePath, kind: "place" as const, title: cur.place.title };
if (cur.kind === "folder")
return { path: cur.folderPath, kind: "folder" as const, title: cur.label };
// List tabs are ephemeral working sets the agent itself produced — not reported.
return { path: cur.place.filePath, kind: "place", title: cur.place.title };
if (cur.kind === "folder") return { path: cur.folderPath, kind: "folder", title: cur.label };
if (cur.kind === "list") {
return {
kind: "feature_list",
layerId: cur.layerId,
title: cur.label,
features: featureListSummaryFromLayer(cur.layer, toRelative)
};
}
// Directions tabs are agent-driven working surfaces — not reported (like lists were).
return null;
};
const infos = nav.tabs.map(toInfo);
const tabs = infos.filter((t): t is NonNullable<typeof t> => t !== null);
const active = nav.activeTab >= 0 ? (infos[nav.activeTab] ?? null) : null;
const tabs = infos.filter((t): t is NavTabInfo => t !== null);
const activeInfo = nav.activeTab >= 0 ? (infos[nav.activeTab] ?? null) : null;
window.api.nav.sendNavState({
active,
activeIndex: active ? tabs.indexOf(active) : -1,
active: activeInfo,
activeIndex: activeInfo ? tabs.indexOf(activeInfo) : -1,
tabs
});
}, [nav]);
}, [nav, vaultRoot]);

/** "My location" control: store the fix for the marker layer and center on it
* through the same padding-aware handle so it isn't hidden behind open panes. */
Expand Down
13 changes: 8 additions & 5 deletions apps/dashboard/src/renderer/src/hooks/use-map-overlay-sync.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import type { MapOverlayLayer } from "@shared/types";
import { useEffect } from "react";

/** Subscribe to `map:overlay-add` from the main process (the agent's present_features) and
* hand each layer to `addLayer`, which opens it as a list tab. There is no overlay-clear
* channel: a shown feature set lives in its tab and clears when the tab closes. */
/** Subscribe to `map:overlay-add` / `map:overlay-update` from the main process (the agent's
* present_features) and hand each layer to the caller. There is no overlay-clear channel: a
* shown feature set lives in its tab and clears when the tab closes. */
export function useMapOverlaySync({
addLayer
addLayer,
updateLayer
}: {
addLayer: (layer: MapOverlayLayer) => void;
updateLayer: (layer: MapOverlayLayer) => void;
}): void {
useEffect(() => {
window.api.map.onOverlayAdd((layer) => addLayer(layer));
window.api.map.onOverlayUpdate((layer) => updateLayer(layer));
return () => window.api.map.removeOverlayListeners();
}, [addLayer]);
}, [addLayer, updateLayer]);
}
11 changes: 10 additions & 1 deletion apps/dashboard/src/renderer/src/hooks/use-nav-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,13 +337,22 @@ export function navReducer(state: NavState, action: NavAction): NavState {
};
}
case "update-list": {
const tabIndex = state.tabs.findIndex((tab) => {
const entry = tab.history[tab.cursor];
return entry?.kind === "list" && entry.layerId === action.layerId;
});
return {
...state,
...(tabIndex >= 0 ? { activeTab: tabIndex } : {}),
tabs: state.tabs.map((tab) => ({
...tab,
history: tab.history.map((entry) =>
entry.kind === "list" && entry.layerId === action.layerId
? { ...entry, layer: action.layer }
? {
...entry,
layer: action.layer,
label: action.layer.layerName || entry.label
}
: entry
)
}))
Expand Down
55 changes: 3 additions & 52 deletions apps/dashboard/src/renderer/src/lib/wikilinks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { FileNode } from "../../../shared/types";
import { resolveWikilinkPath, wikilinkForFile } from "../../../shared/wikilinks";

export { resolveWikilinkPath, wikilinkForFile };

export type VaultMdFile = {
title: string;
Expand Down Expand Up @@ -45,55 +48,3 @@ export function resolveWikilinkTarget<T extends VaultMdFile>(
): T | undefined {
return files.find((f) => f.relPath === link) ?? files.find((f) => f.title === link);
}

/** Vault-relative path without the `.md` extension, `/`-separated. */
function relPathNoExt(filePath: string, vaultRoot: string): string {
const rel =
vaultRoot && filePath.startsWith(vaultRoot)
? filePath.slice(vaultRoot.length).replace(/^[/\\]/, "")
: filePath;
return rel.replace(/\.md$/i, "").replace(/\\/g, "/");
}

function titleOfPath(filePath: string): string {
return (filePath.split(/[/\\]/).pop() ?? filePath).replace(/\.md$/i, "");
}

/**
* The `[[link]]` text for a vault file — its bare filename when that is unique in the vault,
* otherwise the vault-relative path, which {@link resolveWikilinkTarget} matches first.
*
* Works off the places index rather than a directory listing so callers stay synchronous.
*/
export function wikilinkForFile(
filePath: string,
vaultRoot: string,
allFilePaths: Iterable<string>
): string {
const title = titleOfPath(filePath);
let sameTitle = 0;
for (const p of allFilePaths) {
if (titleOfPath(p) === title && ++sameTitle > 1) break;
}
return `[[${sameTitle > 1 ? relPathNoExt(filePath, vaultRoot) : title}]]`;
}

/**
* Reverse of {@link wikilinkForFile}: `[[link]]` text → an absolute vault path, or null when
* nothing matches (the target was renamed or deleted — links are never rewritten on rename).
* Mirrors {@link resolveWikilinkTarget}'s precedence: exact relative path, then filename.
*/
export function resolveWikilinkPath(
link: string,
vaultRoot: string,
allFilePaths: Iterable<string>
): string | null {
const needle = link.trim().replace(/^\[\[/, "").replace(/\]\]$/, "").trim();
if (!needle) return null;
let byTitle: string | null = null;
for (const p of allFilePaths) {
if (relPathNoExt(p, vaultRoot) === needle) return p;
if (byTitle === null && titleOfPath(p) === needle) byTitle = p;
}
return byTitle;
}
Loading