diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..ca941dd --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,118 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { ArrowRight, Search, X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { cn } from "../lib/utils"; +import { navigationItems } from "../navigation"; +import { IconButton } from "./ui"; + +export function CommandPalette({ + open, + onOpenChange +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [query, setQuery] = useState(""); + const navigate = useNavigate(); + + useEffect(() => { + if (!open) setQuery(""); + }, [open]); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return navigationItems; + return navigationItems.filter((item) => + [item.label, item.description, item.group, ...item.keywords] + .join(" ") + .toLowerCase() + .includes(needle) + ); + }, [query]); + + function go(to: string) { + navigate(to); + onOpenChange(false); + } + + return ( + + + + + Search navigation + + Find and open a DocSentinel workspace. + +
+
+
+ {filtered.length ? ( + filtered.map((item) => { + const Icon = item.icon; + return ( + + + + ); + }) + ) : ( +
+
No page found
+
+ Try a workflow, tool, or page name. +
+
+ )} +
+
+ {filtered.length} destinations + Press Esc to close +
+
+
+
+ ); +} diff --git a/frontend/src/components/Layout.test.tsx b/frontend/src/components/Layout.test.tsx new file mode 100644 index 0000000..bb18e4c --- /dev/null +++ b/frontend/src/components/Layout.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AppProviders } from "../app/providers"; +import Layout from "./Layout"; + +function renderLayout() { + return render( + + + + }> + Workspace content} /> + Gateway content} /> + + + + + ); +} + +describe("Layout", () => { + beforeEach(() => { + const values = new Map(); + vi.stubGlobal("localStorage", { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + key: (index: number) => [...values.keys()][index] ?? null, + length: 0, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ status: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("groups navigation by user workflow", () => { + renderLayout(); + + expect(screen.getByText("Review & govern")).toBeInTheDocument(); + expect(screen.getByText("Agent operations")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Assessment queue" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Agent gateway" }) + ).toBeInTheDocument(); + }); + + it("opens the command palette with the standard keyboard shortcut", async () => { + const user = userEvent.setup(); + renderLayout(); + + fireEvent.keyDown(window, { key: "k", ctrlKey: true }); + const dialog = screen.getByRole("dialog", { name: "Search navigation" }); + expect(dialog).toBeInTheDocument(); + + await user.type( + screen.getByRole("textbox", { name: "Search navigation" }), + "mcp" + ); + expect( + screen.getByRole("button", { name: /Agent gateway.*Agent operations/ }) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a11e31c..952e1ab 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,182 +1,319 @@ import * as Dialog from "@radix-ui/react-dialog"; import { useQuery } from "@tanstack/react-query"; import { - BrainCircuit, - Database, - FolderKanban, - Gauge, - ListChecks, + ChevronRight, + FilePlus2, Menu, - Network, - Settings, + Search, ShieldCheck, - X, - type LucideIcon + UserRound, + X } from "lucide-react"; -import { NavLink, Outlet } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Link, NavLink, Outlet, useLocation } from "react-router-dom"; import { getHealth } from "../lib/api"; +import { getGovernanceSession } from "../lib/governanceApi"; import { cn } from "../lib/utils"; -import { Badge, IconButton } from "./ui"; - -type NavItem = { - to: string; - label: string; - icon: LucideIcon; -}; - -const navItems: NavItem[] = [ - { to: "/", label: "Dashboard", icon: Gauge }, - { to: "/assessments", label: "Assessments", icon: ListChecks }, - { to: "/governance", label: "Governance", icon: FolderKanban }, - { to: "/kb", label: "Knowledge Base", icon: Database }, - { to: "/skills", label: "Skills", icon: BrainCircuit }, - { to: "/integrations", label: "Agent Integrations", icon: Network }, - { to: "/settings", label: "Settings", icon: Settings } -]; - -function Brand() { +import { + navigationGroups, + navigationItemForPath, + type NavigationItem +} from "../navigation"; +import { CommandPalette } from "./CommandPalette"; +import { Badge, Button, IconButton } from "./ui"; + +function Brand({ compact = false }: { compact?: boolean }) { return ( -
-
+ +
-
-
DocSentinel
-
Trustworthy review
-
-
+ {!compact ? ( +
+
+ DocSentinel +
+
+ Security review workspace +
+
+ ) : null} + ); } +function NavigationLink({ + item, + mobile +}: { + item: NavigationItem; + mobile: boolean; +}) { + const Icon = item.icon; + const link = ( + + cn( + "focus-ring group relative flex min-h-10 items-center gap-3 rounded-lg px-3 py-2 text-sm transition", + isActive + ? "bg-accent/10 font-medium text-text" + : "text-muted hover:bg-panel2/80 hover:text-text" + ) + } + > + {({ isActive }) => ( + <> + + ); + + return mobile ? {link} : link; +} + function Navigation({ mobile = false }: { mobile?: boolean }) { return ( -