From d634ee89d4d3327bf344dcd2e1247d4f57a95e6c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 24 Aug 2026 17:26:03 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20header=20IA=20=E2=80=94=20project=20?= =?UTF-8?q?switcher=20first,=20no=20dead=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: the top bar mixed project-scoped tabs with app-level ones and left the project tabs visible-but-dead until a project was chosen elsewhere. - The project is now a first-class switcher directly after the wordmark (dropdown: all indexed projects + a manage-and-index row); picking one lands on its Overview. - Project tabs (Overview...Dashboard) render only when a project is selected - a control that cannot work does not render, same trust rule as everywhere else. A divider separates context from views. - App-level destinations sit quietly on the right: search (only with a project) and Control. The redundant right-side project chip is gone; the Projects tab is reachable through the switcher and remains the no-project landing page. UI-only change; vitest 66/66, tsc clean. Signed-off-by: Martin Vogel --- graph-ui/src/App.tsx | 97 +++++++++--------- graph-ui/src/components/ProjectSwitcher.tsx | 106 ++++++++++++++++++++ 2 files changed, 155 insertions(+), 48 deletions(-) create mode 100644 graph-ui/src/components/ProjectSwitcher.tsx diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 2742da707..d79f85bae 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -9,6 +9,7 @@ import { FlowsTab } from "./components/FlowsTab"; import { ChangesTab } from "./components/ChangesTab"; import { DashboardTab } from "./components/DashboardTab"; import { CommandK } from "./components/CommandK"; +import { ProjectSwitcher } from "./components/ProjectSwitcher"; import { PromptBasketProvider, PromptBasketDrawer, @@ -29,7 +30,6 @@ const TAB_IDS: TabId[] = [ ]; /* Tabs that need a selected project. "symbol" is reachable only via links. */ -const PROJECT_TABS: TabId[] = ["overview", "modules", "graph", "flows", "changes", "dashboard"]; interface RouteState { tab: TabId; @@ -140,15 +140,16 @@ export function App() { [navigate], ); - const tabs: { id: TabId; label: string }[] = [ + /* Project-scoped views only — they render once a project is chosen. + * App-level destinations (Projects, Control) live on the right instead of + * being mixed into this row as dead buttons. */ + const projectTabs: { id: TabId; label: string }[] = [ { id: "overview", label: "Overview" }, { id: "modules", label: "Modules" }, { id: "graph", label: "Galaxy" }, { id: "flows", label: "Flows" }, { id: "changes", label: "Changes" }, { id: "dashboard", label: "Dashboard" }, - { id: "stats", label: t.tabs.projects }, - { id: "control", label: t.tabs.control }, ]; const symbolRef = route.sym @@ -162,7 +163,7 @@ export function App() {
{/* Header */}
-
+
@@ -170,56 +171,56 @@ export function App() {
- + }`} + > + {tab.label} + + ))} + + + )}
- {selectedProject && ( -
- - {selectedProject} - - -
+ )} +
diff --git a/graph-ui/src/components/ProjectSwitcher.tsx b/graph-ui/src/components/ProjectSwitcher.tsx new file mode 100644 index 000000000..b61773faf --- /dev/null +++ b/graph-ui/src/components/ProjectSwitcher.tsx @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { callTool } from "../api/rpc"; +import type { Project } from "../lib/types"; + +/* The project is the context every other view depends on, so it lives + * first in the header as a switcher — not as a trailing chip gating a row + * of dead tabs. */ +export function ProjectSwitcher({ + selected, + allProjectsLabel, + onSelect, + onAllProjects, +}: { + selected: string | null; + allProjectsLabel: string; + onSelect: (project: string) => void; + onAllProjects: () => void; +}) { + const [open, setOpen] = useState(false); + const [projects, setProjects] = useState(null); + const rootRef = useRef(null); + + const load = useCallback(() => { + if (projects !== null) return; + callTool<{ projects: Project[] }>("list_projects", {}) + .then((result) => setProjects(result.projects ?? [])) + .catch(() => setProjects([])); + }, [projects]); + + useEffect(() => { + if (!open) return; + const onDocClick = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) + setOpen(false); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( +
+ + {open && ( +
+ {projects === null && ( +

Loading…

+ )} + {projects !== null && projects.length === 0 && ( +

+ No indexed projects yet. +

+ )} + {(projects ?? []).map((project) => ( + + ))} +
+ +
+
+ )} +
+ ); +}