fix(ui): header IA — project switcher first, no dead tabs

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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-24 17:26:03 +02:00
parent e44ef0a917
commit d634ee89d4
2 changed files with 155 additions and 48 deletions
+49 -48
View File
@@ -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() {
<div className="h-screen flex flex-col bg-background text-foreground">
{/* Header */}
<header className="flex items-center justify-between px-5 h-12 border-b border-border bg-card/80 backdrop-blur-md shrink-0">
<div className="flex items-center gap-6 min-w-0">
<div className="flex items-center gap-4 min-w-0">
<div className="flex items-center gap-2.5 shrink-0">
<div className="w-[7px] h-[7px] rounded-full bg-primary" />
<span className="text-[13px] font-semibold text-foreground/90 tracking-tight">
@@ -170,56 +171,56 @@ export function App() {
</span>
</div>
<nav className="flex items-center gap-0.5 overflow-x-auto">
{tabs.map((tab) => {
const disabled = PROJECT_TABS.includes(tab.id) && !selectedProject;
return (
<button
key={tab.id}
onClick={() =>
navigate({
tab: tab.id,
project: tab.id === "stats" ? null : selectedProject,
})
}
disabled={disabled}
title={disabled ? "Select a project first" : undefined}
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all whitespace-nowrap ${
disabled
? "text-muted-foreground/50 cursor-not-allowed"
: activeTab === tab.id
{/* Context first: which project is everything below about? */}
<ProjectSwitcher
selected={selectedProject}
allProjectsLabel={t.tabs.projects}
onSelect={(project) => navigate({ tab: "overview", project })}
onAllProjects={() => navigate({ tab: "stats", project: null })}
/>
{selectedProject && (
<>
<div className="w-px h-5 bg-border/50 shrink-0" />
<nav className="flex items-center gap-0.5 overflow-x-auto">
{projectTabs.map((tab) => (
<button
key={tab.id}
onClick={() => navigate({ tab: tab.id, project: selectedProject })}
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all whitespace-nowrap ${
activeTab === tab.id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-surface-3"
}`}
>
{tab.label}
</button>
);
})}
</nav>
}`}
>
{tab.label}
</button>
))}
</nav>
</>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => setCommandOpen(true)}
className="px-2.5 py-1 rounded-md text-[13px] text-muted-foreground hover:text-foreground bg-popover hover:bg-surface-3 border border-border/40 transition-all"
title="Search symbols and code"
>
⌘K
</button>
{selectedProject && (
<div className="flex items-center gap-2 px-3 py-1 rounded-md bg-popover border border-border/30">
<span className="text-[13px] text-primary font-mono truncate max-w-[240px]">
{selectedProject}
</span>
<button
onClick={() => navigate({ tab: "stats", project: null })}
className="text-foreground/35 hover:text-foreground/50 text-[12px] transition-colors"
>
×
</button>
</div>
<button
onClick={() => setCommandOpen(true)}
className="px-2.5 py-1 rounded-md text-[13px] text-muted-foreground hover:text-foreground bg-popover hover:bg-surface-3 border border-border/40 transition-all"
title="Search symbols and code"
>
⌘K
</button>
)}
<button
onClick={() => navigate({ tab: "control", project: selectedProject })}
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
activeTab === "control"
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-surface-3"
}`}
>
{t.tabs.control}
</button>
</div>
</header>
+106
View File
@@ -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<Project[] | null>(null);
const rootRef = useRef<HTMLDivElement | null>(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 (
<div ref={rootRef} className="relative shrink-0">
<button
onClick={() => {
setOpen((v) => !v);
load();
}}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-[13px] transition-all max-w-[280px] ${
selected
? "bg-popover border-border/40 text-foreground/85 hover:border-border"
: "bg-primary/15 border-primary/40 text-primary hover:bg-primary/25"
}`}
title={selected ?? "Choose which indexed project to explore"}
>
<span className="font-mono truncate">{selected ?? "Select a project…"}</span>
<span className="text-foreground/40 text-[11px] shrink-0">▾</span>
</button>
{open && (
<div className="absolute top-full left-0 mt-1 w-[320px] bg-popover border border-border/60 rounded-md shadow-xl z-30 py-1 max-h-[60vh] overflow-y-auto">
{projects === null && (
<p className="px-3 py-2 text-[13px] text-foreground/40">Loading…</p>
)}
{projects !== null && projects.length === 0 && (
<p className="px-3 py-2 text-[13px] text-foreground/40">
No indexed projects yet.
</p>
)}
{(projects ?? []).map((project) => (
<button
key={project.name}
onClick={() => {
setOpen(false);
onSelect(project.name);
}}
className={`flex items-center gap-2 w-full text-left px-3 py-1.5 transition-colors hover:bg-surface-3 ${
project.name === selected ? "text-primary" : "text-foreground/70"
}`}
>
<span className="text-[13px] font-mono truncate">{project.name}</span>
{project.name === selected && (
<span className="ml-auto text-[11px] shrink-0">●</span>
)}
</button>
))}
<div className="border-t border-border/40 mt-1 pt-1">
<button
onClick={() => {
setOpen(false);
onAllProjects();
}}
className="w-full text-left px-3 py-1.5 text-[13px] text-foreground/55 hover:text-foreground/85 hover:bg-surface-3 transition-colors"
>
{allProjectsLabel} — manage &amp; index →
</button>
</div>
</div>
)}
</div>
);
}