Layout Components
Structural layout components for the MetaForge Digital Twin Dashboard. These components provide the application shell: sidebar navigation, top bar, breadcrumbs, and reusable page headers.
src/components/layout/sidebar.tsx
Full sidebar navigation with collapsible icon-only mode, active link highlighting via React Router NavLink, a system health indicator, and version display. Collapsed state is persisted through the ui-store.
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
Activity,
Bot,
Box,
List,
Shield,
TestTube,
Truck,
CheckCircle,
Settings,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useUIStore } from "@/store/ui-store";
import { useWebSocketStore, type WSStatus } from "@/store/websocket-store";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
// ---------------------------------------------------------------------------
// Navigation config
// ---------------------------------------------------------------------------
interface NavItem {
label: string;
to: string;
icon: React.ComponentType<{ className?: string }>;
/** When provided, a small badge count renders next to the label. */
badge?: number;
}
const NAV_ITEMS: NavItem[] = [
{ label: "Overview", to: "/", icon: LayoutDashboard },
{ label: "Sessions", to: "/sessions", icon: Activity },
{ label: "Agents", to: "/agents", icon: Bot },
{ label: "Digital Twin", to: "/digital-twin", icon: Box },
{ label: "BOM", to: "/bom", icon: List },
{ label: "Compliance", to: "/compliance", icon: Shield },
{ label: "Testing", to: "/testing", icon: TestTube },
{ label: "Supply Chain", to: "/supply-chain", icon: Truck },
{ label: "Approvals", to: "/approvals", icon: CheckCircle },
{ label: "Settings", to: "/settings", icon: Settings },
];
const APP_VERSION = "0.1.0";
// ---------------------------------------------------------------------------
// Health dot helper
// ---------------------------------------------------------------------------
function healthColor(status: WSStatus): string {
switch (status) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-amber-500 animate-pulse-dot";
case "disconnected":
return "bg-red-500";
case "error":
return "bg-red-500";
default:
return "bg-muted-foreground";
}
}
function healthLabel(status: WSStatus): string {
switch (status) {
case "connected":
return "System Online";
case "connecting":
return "Connecting...";
case "disconnected":
return "Disconnected";
case "error":
return "Connection Error";
default:
return "Unknown";
}
}
// ---------------------------------------------------------------------------
// Sidebar Component
// ---------------------------------------------------------------------------
export interface SidebarProps {
/** Override pending approval count (e.g. from a query). */
pendingApprovals?: number;
}
export function Sidebar({ pendingApprovals = 0 }: SidebarProps) {
const collapsed = useUIStore((s) => s.sidebarCollapsed);
const setSidebarCollapsed = useUIStore((s) => s.setSidebarCollapsed);
const wsStatus = useWebSocketStore((s) => s.status);
// Merge the dynamic badge into nav items
const items = NAV_ITEMS.map((item) =>
item.to === "/approvals" && pendingApprovals > 0
? { ...item, badge: pendingApprovals }
: item,
);
return (
<TooltipProvider delayDuration={0}>
<aside
className={cn(
"flex h-screen flex-col border-r bg-sidebar-background text-sidebar-foreground transition-all duration-300",
collapsed ? "w-16" : "w-60",
)}
>
{/* ----- Logo ----- */}
<div className="flex h-14 items-center gap-2 border-b px-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
<Box className="h-4 w-4" />
</div>
{!collapsed && (
<span className="text-lg font-bold tracking-tight">
MetaForge
</span>
)}
</div>
{/* ----- Navigation ----- */}
<ScrollArea className="flex-1 py-2">
<nav className="flex flex-col gap-1 px-2">
{items.map((item) => {
const Icon = item.icon;
const linkContent = (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/"}
className={({ isActive }) =>
cn(
"group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground/70",
collapsed && "justify-center px-0",
)
}
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && (
<>
<span className="truncate">{item.label}</span>
{item.badge != null && item.badge > 0 && (
<span className="ml-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground">
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
</>
)}
</NavLink>
);
// In collapsed mode, wrap each link in a tooltip
if (collapsed) {
return (
<Tooltip key={item.to}>
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2">
{item.label}
{item.badge != null && item.badge > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground">
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
</TooltipContent>
</Tooltip>
);
}
return linkContent;
})}
</nav>
</ScrollArea>
{/* ----- Footer: collapse toggle, health, version ----- */}
<div className="mt-auto border-t p-2">
{/* Collapse toggle */}
<Button
variant="ghost"
size="sm"
className={cn(
"mb-2 w-full justify-center",
!collapsed && "justify-start",
)}
onClick={() => setSidebarCollapsed(!collapsed)}
>
{collapsed ? (
<ChevronsRight className="h-4 w-4" />
) : (
<>
<ChevronsLeft className="h-4 w-4" />
<span className="ml-2 text-xs">Collapse</span>
</>
)}
</Button>
<Separator className="mb-2" />
{/* System health */}
<div
className={cn(
"flex items-center gap-2 px-3 py-1.5",
collapsed && "justify-center px-0",
)}
>
<span
className={cn("h-2 w-2 shrink-0 rounded-full", healthColor(wsStatus))}
aria-label={healthLabel(wsStatus)}
/>
{!collapsed && (
<span className="text-xs text-sidebar-foreground/60">
{healthLabel(wsStatus)}
</span>
)}
</div>
{/* Version */}
{!collapsed && (
<p className="px-3 py-1 text-[10px] text-sidebar-foreground/40">
v{APP_VERSION}
</p>
)}
</div>
</aside>
</TooltipProvider>
);
}
src/components/layout/topbar.tsx
Top bar with a hamburger / collapse toggle, breadcrumbs, search button (opens command palette), WebSocket connection status indicator, theme toggle, notification bell placeholder, and user avatar.
import {
Menu,
Search,
Sun,
Moon,
Bell,
User,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useUIStore, resolveTheme } from "@/store/ui-store";
import { useWebSocketStore, type WSStatus } from "@/store/websocket-store";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
// ---------------------------------------------------------------------------
// Connection status helpers
// ---------------------------------------------------------------------------
function wsStatusColor(status: WSStatus): string {
switch (status) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-amber-500 animate-pulse-dot";
case "disconnected":
case "error":
return "bg-red-500";
default:
return "bg-muted-foreground";
}
}
function wsStatusLabel(status: WSStatus): string {
switch (status) {
case "connected":
return "Connected";
case "connecting":
return "Connecting...";
case "disconnected":
return "Disconnected";
case "error":
return "Error";
default:
return "Unknown";
}
}
// ---------------------------------------------------------------------------
// Topbar Component
// ---------------------------------------------------------------------------
export function Topbar() {
const theme = useUIStore((s) => s.theme);
const setTheme = useUIStore((s) => s.setTheme);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
const setSidebarCollapsed = useUIStore((s) => s.setSidebarCollapsed);
const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette);
const wsStatus = useWebSocketStore((s) => s.status);
const reconnectAttempts = useWebSocketStore((s) => s.reconnectAttempts);
const resolvedTheme = resolveTheme(theme);
const isDark = resolvedTheme === "dark";
function handleThemeToggle() {
setTheme(isDark ? "light" : "dark");
}
return (
<TooltipProvider delayDuration={300}>
<header className="sticky top-0 z-40 flex h-14 items-center gap-4 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60">
{/* Mobile: hamburger / Desktop: collapse toggle */}
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={toggleSidebar}
aria-label="Toggle sidebar"
>
<Menu className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="hidden md:inline-flex"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
aria-label="Collapse sidebar"
>
<Menu className="h-5 w-5" />
</Button>
{/* Breadcrumbs */}
<div className="flex-1 overflow-hidden">
<Breadcrumbs />
</div>
{/* Right-side actions */}
<div className="flex items-center gap-1">
{/* Search (Cmd+K) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={toggleCommandPalette}
aria-label="Search"
>
<Search className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
Search{" "}
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-[10px] font-mono">
Ctrl+K
</kbd>
</p>
</TooltipContent>
</Tooltip>
{/* Connection status */}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 rounded-md px-2 py-1.5">
<span
className={cn("h-2 w-2 rounded-full", wsStatusColor(wsStatus))}
/>
<span className="hidden text-xs text-muted-foreground sm:inline">
{wsStatusLabel(wsStatus)}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1 text-xs">
<p>WebSocket: {wsStatusLabel(wsStatus)}</p>
{wsStatus !== "connected" && reconnectAttempts > 0 && (
<p>Reconnect attempts: {reconnectAttempts}</p>
)}
<p className="text-muted-foreground">
{import.meta.env.VITE_WS_URL ?? "ws://localhost:3000/ws"}
</p>
</div>
</TooltipContent>
</Tooltip>
{/* Theme toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleThemeToggle}
aria-label="Toggle theme"
>
{isDark ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isDark ? "Switch to light mode" : "Switch to dark mode"}
</TooltipContent>
</Tooltip>
{/* Agent Chat toggle — supersedes the Bell notification icon */}
{/* See: Agent Chat Channel (chat-channel.md) */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={toggleChatSidebar} aria-label="Chat">
<MessageSquare className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Agent Chat</TooltipContent>
</Tooltip>
{/* User avatar placeholder */}
<Button
variant="ghost"
size="icon"
className="rounded-full"
aria-label="User menu"
>
<User className="h-4 w-4" />
</Button>
</div>
</header>
</TooltipProvider>
);
}
src/components/layout/breadcrumbs.tsx
Auto-generates breadcrumbs from the current React Router location. Maps URL path segments to human-readable names. The last segment is rendered as plain text (not a link).
import { Link, useLocation } from "react-router-dom";
import { ChevronRight, Home } from "lucide-react";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Segment-to-label map
// ---------------------------------------------------------------------------
const SEGMENT_LABELS: Record<string, string> = {
sessions: "Sessions",
agents: "Agents",
"digital-twin": "Digital Twin",
bom: "BOM",
compliance: "Compliance",
testing: "Testing",
"supply-chain": "Supply Chain",
approvals: "Approvals",
settings: "Settings",
};
/**
* Attempt to produce a human-readable label for a path segment.
* Falls back to title-casing the segment if not found in the map.
*/
function labelFor(segment: string): string {
if (SEGMENT_LABELS[segment]) {
return SEGMENT_LABELS[segment];
}
// Title-case fallback (e.g. session ID or unknown route)
return segment
.replace(/-/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
// ---------------------------------------------------------------------------
// Breadcrumbs Component
// ---------------------------------------------------------------------------
export function Breadcrumbs() {
const location = useLocation();
// Split pathname into non-empty segments
const segments = location.pathname
.split("/")
.filter((s) => s.length > 0);
// Build cumulative paths for each segment
const crumbs = segments.map((segment, index) => ({
label: labelFor(segment),
path: "/" + segments.slice(0, index + 1).join("/"),
}));
return (
<nav aria-label="Breadcrumb" className="flex items-center gap-1.5 text-sm">
{/* Home link */}
<Link
to="/"
className={cn(
"flex items-center text-muted-foreground transition-colors hover:text-foreground",
segments.length === 0 && "text-foreground",
)}
>
<Home className="h-3.5 w-3.5" />
<span className="sr-only">Home</span>
</Link>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1;
return (
<div key={crumb.path} className="flex items-center gap-1.5">
<ChevronRight className="h-3 w-3 text-muted-foreground" />
{isLast ? (
<span className="font-medium text-foreground truncate max-w-[200px]">
{crumb.label}
</span>
) : (
<Link
to={crumb.path}
className="text-muted-foreground transition-colors hover:text-foreground truncate max-w-[200px]"
>
{crumb.label}
</Link>
)}
</div>
);
})}
</nav>
);
}
src/components/layout/page-header.tsx
Reusable page header component with a title, optional description, and a slot for right-aligned action buttons.
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PageHeaderProps {
/** Primary heading text. */
title: string;
/** Optional subtitle / description text rendered below the title. */
description?: string;
/** Optional children rendered on the right side (e.g. action buttons). */
children?: React.ReactNode;
/** Additional class names for the wrapper. */
className?: string;
}
// ---------------------------------------------------------------------------
// PageHeader Component
// ---------------------------------------------------------------------------
export function PageHeader({
title,
description,
children,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
"flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between",
className,
)}
>
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{children && (
<div className="flex items-center gap-2 pt-2 sm:pt-0">{children}</div>
)}
</div>
);
}
ChatSidebaris mounted as a sibling of the main content area inroot-layout.tsx, outside the scroll container. It uses the shadcn/uiSheetcomponent and is toggled by theMessageSquareicon in the topbar. See Agent Chat Channel for full specification.
Usage Example
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
function BomPage() {
return (
<div className="space-y-6 p-6">
<PageHeader
title="Bill of Materials"
description="Interactive BOM with lifecycle risk scores and alternate parts lookup."
>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Component
</Button>
</PageHeader>
{/* ... page content ... */}
</div>
);
}