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>
  );
}

ChatSidebar is mounted as a sibling of the main content area in root-layout.tsx, outside the scroll container. It uses the shadcn/ui Sheet component and is toggled by the MessageSquare icon 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>
  );
}