/** * @file Sidebar.tsx * @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../lib/types` * * ## Public surface * - `Sidebar` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **Sidebar** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { NavLink } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useDialog } from "../hooks/useDialog"; import { LayoutDashboard, FolderOpen, BarChart3, Settings, Wifi, WifiOff, PanelLeftClose, PanelLeftOpen, RefreshCw, X, Plug, Clock, Gauge, ChevronUp, ChevronDown, FolderTree, TableProperties, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { WorkspaceTree } from "./WorkspaceTree"; import type { UpdateStatusPayload, WSMessage } from "../lib/types"; function isUpdatePayload(x: unknown): x is UpdateStatusPayload { return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x; } const NAV_KEYS = [ { to: "/", icon: LayoutDashboard, key: "nav:dashboard" }, { to: "/sessions", icon: FolderOpen, key: "nav:sessions" }, { to: "/query", icon: TableProperties, key: "nav:query" }, ] as const; // SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is // owned by the brand, not vendored here — a machine with no network shows the // alt text instead. const BRAND_LOGO = "https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png"; const BRAND_MARK = "https://smartgift.vn/favicon.ico"; const STORAGE_KEY = "sidebar-collapsed"; const STATS_STORAGE_KEY = "sidebar-connection-stats"; const RECENT_EVENTS_CAP = 8; interface PersistedStats { eventCount: number; peakPerSec: number; lastEvent: { type: string; at: number } | null; typeCount: [string, number][]; recentEvents: { type: string; at: number }[]; } /** Below Tailwind's `lg` a full 15rem sidebar eats too much of the page. */ const NARROW_QUERY = "(max-width: 1023.98px)"; /** The stored choice, else collapsed on a narrow viewport. */ function loadCollapsed(): boolean { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored !== null) return stored === "true"; } catch { /* storage off: fall through to the viewport */ } return window.matchMedia?.(NARROW_QUERY).matches ?? false; } function loadStats(): PersistedStats { const empty: PersistedStats = { eventCount: 0, peakPerSec: 0, lastEvent: null, typeCount: [], recentEvents: [], }; try { const raw = localStorage.getItem(STATS_STORAGE_KEY); if (!raw) return empty; const parsed = JSON.parse(raw) as Partial; return { eventCount: typeof parsed.eventCount === "number" ? parsed.eventCount : 0, peakPerSec: typeof parsed.peakPerSec === "number" ? parsed.peakPerSec : 0, lastEvent: parsed.lastEvent && typeof parsed.lastEvent.type === "string" && typeof parsed.lastEvent.at === "number" ? parsed.lastEvent : null, typeCount: Array.isArray(parsed.typeCount) ? parsed.typeCount.filter( (e): e is [string, number] => Array.isArray(e) && typeof e[0] === "string" && typeof e[1] === "number" ) : [], recentEvents: Array.isArray(parsed.recentEvents) ? parsed.recentEvents .filter( (e): e is { type: string; at: number } => !!e && typeof e.type === "string" && typeof e.at === "number" ) .slice(0, RECENT_EVENTS_CAP) : [], }; } catch { return empty; } } interface SidebarProps { wsConnected: boolean; collapsed: boolean; onToggle: () => void; /** The connection details modal, opened from here or from the status bar. */ connectionOpen: boolean; onConnectionOpenChange: (open: boolean) => void; /** Told whether a newer release is out, for the status bar's dot. */ onUpdateAvailableChange?: (available: boolean) => void; /** Set on a narrow viewport: the sidebar is an off-canvas drawer. */ drawer?: { open: boolean; onClose: () => void }; /** Expanded width in px, chosen by dragging its edge (see Layout). */ width?: number; /** True while that edge is dragged: no width transition lagging the pointer. */ resizing?: boolean; } export function Sidebar({ wsConnected, collapsed, onToggle, connectionOpen, onConnectionOpenChange, onUpdateAvailableChange, drawer, width = 240, resizing = false, }: SidebarProps) { const { t } = useTranslation(); const asideRef = useRef(null); const drawerOpen = Boolean(drawer?.open); useDialog(drawerOpen, asideRef, drawer?.onClose); // Track whether nav items are clipped by overflow so we can render // chevron affordances pointing toward the hidden items. Recomputed on // scroll, resize, and any structural change (e.g. collapse toggle). const navRef = useRef(null); const [navOverflow, setNavOverflow] = useState({ up: false, down: false }); const [updateStatus, setUpdateStatus] = useState(null); const [checking, setChecking] = useState(false); const [checkError, setCheckError] = useState(false); const [connectedSince, setConnectedSince] = useState( wsConnected ? Date.now() : null ); // Buffers live in refs so the sidebar isn't re-rendered on every WS event - // the modal samples them on its own tick while it's open. Cumulative buffers // (count, type breakdown, recent list) are hydrated from localStorage so they // survive page reloads; the rolling 60s sparkline buffer is intentionally // ephemeral since it's only meaningful relative to "now". const eventCountRef = useRef(0); const peakPerSecRef = useRef(0); const lastEventRef = useRef<{ type: string; at: number } | null>(null); const eventTimestampsRef = useRef([]); const typeCountRef = useRef>(new Map()); const recentEventsRef = useRef>([]); const persistTimerRef = useRef | null>(null); const recomputeNavOverflow = useCallback(() => { const el = navRef.current; if (!el) return; const up = el.scrollTop > 1; const down = el.scrollTop + el.clientHeight < el.scrollHeight - 1; setNavOverflow((prev) => (prev.up === up && prev.down === down ? prev : { up, down })); }, []); useEffect(() => { recomputeNavOverflow(); const el = navRef.current; if (!el) return; const onScroll = () => recomputeNavOverflow(); el.addEventListener("scroll", onScroll, { passive: true }); const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(recomputeNavOverflow) : null; ro?.observe(el); window.addEventListener("resize", recomputeNavOverflow); return () => { el.removeEventListener("scroll", onScroll); ro?.disconnect(); window.removeEventListener("resize", recomputeNavOverflow); }; }, [recomputeNavOverflow, collapsed]); const scrollNavBy = useCallback((delta: number) => { navRef.current?.scrollBy({ top: delta, behavior: "smooth" }); }, []); // Hydrate from localStorage on mount. useEffect(() => { const stats = loadStats(); eventCountRef.current = stats.eventCount; peakPerSecRef.current = stats.peakPerSec; lastEventRef.current = stats.lastEvent; typeCountRef.current = new Map(stats.typeCount); recentEventsRef.current = stats.recentEvents; }, []); const persistStats = useCallback(() => { try { const payload: PersistedStats = { eventCount: eventCountRef.current, peakPerSec: peakPerSecRef.current, lastEvent: lastEventRef.current, typeCount: Array.from(typeCountRef.current.entries()), recentEvents: recentEventsRef.current, }; localStorage.setItem(STATS_STORAGE_KEY, JSON.stringify(payload)); } catch { /* ignore quota / disabled storage */ } }, []); const schedulePersist = useCallback(() => { if (persistTimerRef.current) return; persistTimerRef.current = setTimeout(() => { persistTimerRef.current = null; persistStats(); }, 2000); }, [persistStats]); // Flush pending writes when the page is being hidden / unloaded so the very // latest events aren't lost to the throttle window. useEffect(() => { const flush = () => { if (persistTimerRef.current) { clearTimeout(persistTimerRef.current); persistTimerRef.current = null; } persistStats(); }; window.addEventListener("pagehide", flush); document.addEventListener("visibilitychange", flush); return () => { window.removeEventListener("pagehide", flush); document.removeEventListener("visibilitychange", flush); if (persistTimerRef.current) clearTimeout(persistTimerRef.current); }; }, [persistStats]); useEffect(() => { return eventBus.subscribe((msg: WSMessage) => { if (msg.type === "update_status") { if (isUpdatePayload(msg.data)) { setUpdateStatus(msg.data); setCheckError(Boolean(msg.data.fetch_error)); } return; } const now = Date.now(); eventCountRef.current += 1; lastEventRef.current = { type: msg.type, at: now }; eventTimestampsRef.current.push(now); // Keep only the last 60 seconds worth of timestamps for the sparkline. const cutoff = now - 60_000; const stamps = eventTimestampsRef.current; while (stamps.length > 0 && (stamps[0] as number) < cutoff) { stamps.shift(); } typeCountRef.current.set(msg.type, (typeCountRef.current.get(msg.type) ?? 0) + 1); recentEventsRef.current.unshift({ type: msg.type, at: now }); if (recentEventsRef.current.length > RECENT_EVENTS_CAP) { recentEventsRef.current.length = RECENT_EVENTS_CAP; } // All-time peak events/sec: count events landing in the trailing 1s // window ending right now. Walk from the tail (newest) backwards and // stop as soon as we cross the threshold - O(k) where k is the size of // the burst, so this stays cheap even under sustained traffic. const oneSecAgo = now - 1000; let inLastSec = 0; for (let i = stamps.length - 1; i >= 0; i--) { if ((stamps[i] as number) >= oneSecAgo) inLastSec += 1; else break; } if (inLastSec > peakPerSecRef.current) { peakPerSecRef.current = inLastSec; } schedulePersist(); }); }, [schedulePersist]); // Track when the live connection most recently came up so the modal can // show an honest "connected since" timestamp instead of stale state. useEffect(() => { if (wsConnected) { setConnectedSince((prev) => prev ?? Date.now()); } else { setConnectedSince(null); } }, [wsConnected]); const onCheckUpdates = async () => { if (checking) return; setChecking(true); setCheckError(false); // Explicit user intent - clear any prior dismissal so the modal can // re-open if this check still reports an update. try { localStorage.removeItem("agent-monitor-update-dismissed-sha"); } catch { /* ignore */ } window.dispatchEvent(new Event("dashboard:reset-update-dismissal")); try { const fresh = await api.updates.check(); setUpdateStatus(fresh); setCheckError(Boolean(fresh.fetch_error)); } catch { setCheckError(true); } finally { setChecking(false); } }; const updateAvailable = Boolean(updateStatus?.update_available); // Four resting states, not two: a release is out, this build is ahead of the // newest release (dev build), it matches the newest release, or no release // could be read at all. Collapsing the middle two into "Up to date" is what // made a checkout carrying unreleased commits look shipped. const checkTitle = checking ? t("nav:checkingForUpdates") : checkError ? t("nav:checkFailed") : updateStatus?.installed_not_running ? t("nav:installedNotRunning", { version: updateStatus.installed_not_running.version ?? "?", }) : updateAvailable ? updateStatus?.latest_release ? t("nav:releaseAvailable", { version: updateStatus.latest_release }) : t("nav:updateAvailable") : updateStatus?.dev_build ? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 }) : // A plugin install has no commit count to fall back on: a release // that could not be read is "unknown", never "up to date". updateStatus?.install_kind === "plugin" && updateStatus.release_check && updateStatus.release_check !== "ok" ? t("nav:releaseUnchecked") : updateStatus ? t("nav:upToDate") : t("nav:checkForUpdates"); useEffect(() => { onUpdateAvailableChange?.(updateAvailable); }, [updateAvailable, onUpdateAvailableChange]); // Same density and selection as a workspace row, so the whole sidebar // reads as one list: no border, the active item tinted with an accent icon. const navClass = ({ isActive }: { isActive: boolean }) => `flex items-center gap-2.5 rounded-md text-[13px] transition-colors duration-150 ${ collapsed ? "justify-center px-2 py-2" : "px-2.5 py-1.5" } ${ isActive ? "bg-accent/10 font-medium text-fg-primary [&>svg]:text-accent" : "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary" }`; return ( ); } interface ConnectionStatusModalProps { open: boolean; onClose: () => void; wsConnected: boolean; connectedSince: number | null; eventCountRef: React.MutableRefObject; peakPerSecRef: React.MutableRefObject; lastEventRef: React.MutableRefObject<{ type: string; at: number } | null>; eventTimestampsRef: React.MutableRefObject; typeCountRef: React.MutableRefObject>; recentEventsRef: React.MutableRefObject>; /** The release check: its one-line state ("Up to date", "Dev build · N * ahead", a release out…) and the button that re-runs it. */ update: { title: string; checking: boolean; available: boolean; failed: boolean; onCheck: () => void; }; onResetStats: () => void; } function ConnectionStatusModal({ open, onClose, wsConnected, connectedSince, eventCountRef, peakPerSecRef, lastEventRef, eventTimestampsRef, typeCountRef, recentEventsRef, update, onResetStats, }: ConnectionStatusModalProps) { const { t } = useTranslation(); const [, forceTick] = useState(0); // Re-render once a second so the sparkline / relative timestamps / counts // stay honest while the modal is open. Cleared on close so we don't burn // cycles in the background. useEffect(() => { if (!open) return; const id = window.setInterval(() => forceTick((n) => n + 1), 1000); return () => window.clearInterval(id); }, [open]); const close = useCallback(() => onClose(), [onClose]); const panelRef = useRef(null); useDialog(open, panelRef, close); const wsUrl = useMemo(() => { if (typeof window === "undefined") return ""; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${window.location.host}/ws`; }, []); if (!open || typeof document === "undefined") return null; const eventCount = eventCountRef.current; const lastEvent = lastEventRef.current; const recentEvents = recentEventsRef.current; const buckets = bucketEventsPerSecond(eventTimestampsRef.current, 60); const eventsLastMinute = buckets.reduce((sum, n) => sum + n, 0); // All-time peak - kept persistently across the session and across reloads, // so a one-off burst doesn't disappear once it rolls off the 60s window. const peakPerSec = peakPerSecRef.current; const avgPerSec = eventsLastMinute / 60; const totalCounted = Array.from(typeCountRef.current.values()).reduce((sum, n) => sum + n, 0); const topTypes = Array.from(typeCountRef.current.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 5); const topMax = topTypes.length > 0 ? (topTypes[0] as [string, number])[1] : 0; return createPortal(
{ if (e.target === e.currentTarget) close(); }} >
{wsConnected ? ( ) : ( )}

{t("nav:connectionDetails")}

{wsConnected && ( )} {wsConnected ? t("nav:live") : t("nav:disconnected")}

{/* Version + release check */}

{`v${__APP_VERSION__}`}

{update.available && ( )} {update.title}

{/* KPI row */}
{/* Throughput sparkline */}
{/* Connection facts */}
{/* Top event types */}
{topTypes.length === 0 ? (

{t("nav:noEventsYet")}

) : (
{topTypes.map(([type, count]) => ( ))}
)}
{/* Recent activity */}
{recentEvents.length === 0 ? (

{t("nav:noEventsYet")}

) : (
    {recentEvents.map((evt, i) => (
  • {evt.type} {formatRelative(evt.at, t)}
  • ))}
)}
{t("nav:statsPersisted")}
, document.body ); } function Section({ title, icon: Icon, children, }: { title: string; icon: LucideIcon; children: React.ReactNode; }) { return (

{title}

{children}
); } function KpiTile({ label, value, unit }: { label: string; value: string; unit: string }) { return (
{label}
{value} {unit}
); } function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
{label} {value}
); } function TypeBar({ type, count, max, total, }: { type: string; count: number; max: number; total: number; }) { const widthPct = max > 0 ? Math.max(2, (count / max) * 100) : 0; const sharePct = total > 0 ? Math.round((count / total) * 100) : 0; return (
{type} {count} · {sharePct}%
); } function Sparkline({ buckets, connected, avgLabel, }: { buckets: number[]; connected: boolean; avgLabel: string; }) { const { t } = useTranslation(); const W = 320; const H = 56; const max = Math.max(1, ...buckets); const stepX = W / Math.max(1, buckets.length - 1); const points = buckets.map((v, i) => { const x = i * stepX; const y = H - (v / max) * (H - 4) - 2; return `${x.toFixed(1)},${y.toFixed(1)}`; }); const linePath = points.length > 0 ? `M ${points.join(" L ")}` : ""; const areaPath = points.length > 0 ? `M 0,${H} L ${points.join(" L ")} L ${W},${H} Z` : ""; const stroke = connected ? "rgb(var(--status-success))" : "rgb(var(--fg-muted))"; return (
{areaPath && } {linePath && ( )}
−60s {avgLabel} {t("nav:now")}
); } function bucketEventsPerSecond(timestamps: number[], windowSec: number): number[] { const now = Date.now(); const buckets = new Array(windowSec).fill(0); for (const ts of timestamps) { const ageSec = Math.floor((now - ts) / 1000); if (ageSec < 0 || ageSec >= windowSec) continue; // Index 0 = 60s ago, last index = now. const idx = windowSec - 1 - ageSec; buckets[idx] = (buckets[idx] ?? 0) + 1; } return buckets; } function formatRelative( timestamp: number, t: (key: string, opts?: Record) => string ): string { const diffSec = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); if (diffSec < 5) return t("nav:justNow"); if (diffSec < 60) return t("nav:secondsAgo", { count: diffSec }); const diffMin = Math.floor(diffSec / 60); if (diffMin < 60) return t("nav:minutesAgo", { count: diffMin }); const diffHr = Math.floor(diffMin / 60); if (diffHr < 24) return t("nav:hoursAgo", { count: diffHr }); const diffDay = Math.floor(diffHr / 24); return t("nav:daysAgo", { count: diffDay }); } export { STORAGE_KEY as SIDEBAR_STORAGE_KEY, loadCollapsed };