mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-09 09:11:00 +08:00
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:
+49
-48
@@ -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>
|
||||
|
||||
|
||||
@@ -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 & index →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user