From c1ec216ea44a640a53ed9c289af51f09b4296470 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 6 Jul 2026 20:07:50 -0700 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E2=8C=98K=20command=20palette=20?= =?UTF-8?q?=E2=80=94=20app-wide=20launcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard twin of the map's inspect panel: ⌘K (or the "Launch…" nav button) from any page. Empty query = the "Start Menu" (browsable launchable apps, local + remote, grouped by machine badge); typing searches every deployment. Enter launches the app in a new tab; per-result actions jump to it on the map (/map?focus=, which the map now honors by selecting + centering) or open its Castle details. Navigation/launch only — no mutation — for v1. --- app/src/components/CommandPalette.tsx | 203 ++++++++++++++++++++++++++ app/src/components/Layout.tsx | 21 +++ app/src/pages/SystemMap.tsx | 14 +- 3 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 app/src/components/CommandPalette.tsx diff --git a/app/src/components/CommandPalette.tsx b/app/src/components/CommandPalette.tsx new file mode 100644 index 0000000..2fe67c4 --- /dev/null +++ b/app/src/components/CommandPalette.tsx @@ -0,0 +1,203 @@ +import { useEffect, useMemo, useState } from "react" +import { useNavigate } from "react-router-dom" +import { ExternalLink, Map as MapIcon, Maximize2, Search } from "lucide-react" +import { useGateway, useGraph, useMeshDeployments } from "@/services/api/hooks" + +// The command palette — the keyboard twin of the map's inspect panel. ⌘K from +// anywhere: an empty query is the "Start Menu" (launchable apps); typing searches +// every deployment. Enter launches (or, for non-launchable, jumps to it on the map). +interface AppItem { + id: string + name: string + kind: string + machine: string | null // remote hostname, or null for local + launchUrl?: string + detailPath: string + mapNodeId: string +} + +function detailPathFor(kind: string, name: string): string { + if (kind === "job") return `/jobs/${name}` + if (kind === "tool") return `/tools/${name}` + if (kind === "reference") return `/map` + return `/services/${name}` +} + +// Parent: owns only the open state + the global ⌘K / event triggers. The body +// mounts fresh each open (so query/selection reset for free). +export function CommandPalette() { + const [open, setOpen] = useState(false) + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault() + setOpen((o) => !o) + } + } + const onOpen = () => setOpen(true) + window.addEventListener("keydown", onKey) + window.addEventListener("open-command-palette", onOpen) + return () => { + window.removeEventListener("keydown", onKey) + window.removeEventListener("open-command-palette", onOpen) + } + }, []) + if (!open) return null + return setOpen(false)} /> +} + +function PaletteBody({ onClose }: { onClose: () => void }) { + const navigate = useNavigate() + const { data: graph } = useGraph() + const { data: gateway } = useGateway() + const { data: mesh } = useMeshDeployments() + const [query, setQuery] = useState("") + const [sel, setSel] = useState(0) + + const apps = useMemo(() => { + const domain = gateway?.domain + const out: AppItem[] = [] + for (const n of graph?.nodes ?? []) { + const launchUrl = + n.kind === "reference" + ? (n.base_url ?? undefined) + : domain && + n.reach && + n.reach !== "off" && + (n.kind === "static" || (n.kind === "service" && n.endpoints.some((e) => e.protocol === "http"))) + ? `https://${n.name}.${domain}` + : undefined + out.push({ + id: n.name, + name: n.name, + kind: n.kind, + machine: null, + launchUrl, + detailPath: detailPathFor(n.kind, n.name), + mapNodeId: n.name, + }) + } + for (const md of mesh?.deployments ?? []) { + out.push({ + id: `${md.node}/${md.name}`, + name: md.name, + kind: md.kind, + machine: md.node, + launchUrl: undefined, // remote launch URL isn't in the mesh payload yet + detailPath: `/node/${md.node}`, + mapNodeId: `__remote_${md.node}_${md.name}__`, + }) + } + return out + }, [graph, gateway, mesh]) + + const results = useMemo(() => { + const q = query.trim().toLowerCase() + // Empty query = the Start Menu: just the launchable apps, browsable. + if (!q) return apps.filter((a) => a.launchUrl) + return apps.filter((a) => a.name.toLowerCase().includes(q) || a.kind.includes(q)) + }, [apps, query]) + + const launch = (a: AppItem) => { + onClose() + if (a.launchUrl) window.open(a.launchUrl, "_blank", "noreferrer") + else navigate(`/map?focus=${encodeURIComponent(a.mapNodeId)}`) + } + const goToMap = (a: AppItem) => { + onClose() + navigate(`/map?focus=${encodeURIComponent(a.mapNodeId)}`) + } + const details = (a: AppItem) => { + onClose() + navigate(a.detailPath) + } + + return ( +
+
e.stopPropagation()} + > +
+ + { + setQuery(e.target.value) + setSel(0) + }} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault() + setSel((s) => Math.min(s + 1, results.length - 1)) + } else if (e.key === "ArrowUp") { + e.preventDefault() + setSel((s) => Math.max(s - 1, 0)) + } else if (e.key === "Enter") { + e.preventDefault() + const r = results[sel] + if (r) launch(r) + } else if (e.key === "Escape") { + onClose() + } + }} + placeholder="Launch or find anything…" + className="flex-1 bg-transparent py-3 text-sm text-[var(--foreground)] outline-none placeholder:text-[var(--muted)]" + /> + esc +
+
+ {results.length === 0 && ( +
No matches.
+ )} + {results.map((a, i) => ( +
setSel(i)} + onClick={() => launch(a)} + className={`flex cursor-pointer items-center gap-2 px-3 py-2 text-sm ${i === sel ? "bg-white/10" : ""}`} + > + {a.name} + {a.machine && ( + {a.machine} + )} + + {a.kind} + + {a.launchUrl && ( + + )} + + +
+ ))} +
+
+ ↑↓ navigate · ↵ launch/open · esc close + + {results.length} {query ? "matches" : "apps"} + +
+
+
+ ) +} diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx index fb302c7..51ba28b 100644 --- a/app/src/components/Layout.tsx +++ b/app/src/components/Layout.tsx @@ -10,6 +10,7 @@ import { LayoutDashboard, Menu, Package, + Search, Server, Share2, Map as MapIcon, @@ -21,6 +22,7 @@ import { import { cn } from "@/lib/utils" import { useEventStream } from "@/services/api/hooks" import { AssistantDock } from "@/components/AssistantDock" +import { CommandPalette } from "@/components/CommandPalette" type NavLeaf = { to: string; label: string; icon: LucideIcon; end?: boolean } type NavGroup = { label: string; icon: LucideIcon; children: NavLeaf[] } @@ -127,6 +129,22 @@ function NavGroupItem({ function NavItems({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) { return (