/** * @file KanbanBoard.tsx * @description Kanban-style board with two views: agents grouped by their * AgentStatus (working/waiting/completed/error) or sessions grouped * by their SessionStatus (active/completed/error/abandoned). The view toggle * is persisted in localStorage so the user's choice survives reloads. Each * column paginates client-side at COLUMN_PAGE_SIZE. * @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. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../components/AgentCard` * - `../components/SessionCard` * - `../components/EmptyState` * - `../components/Skeleton` * - `../lib/types` * * ## Public surface * - `KanbanBoard` — 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. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **KanbanBoard** * 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 { useEffect, useState, useCallback, useMemo, useRef, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { RefreshCw, Columns3, ChevronDown, HelpCircle } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { AgentCard } from "../components/AgentCard"; import { SessionCard } from "../components/SessionCard"; import { EmptyState } from "../components/EmptyState"; import { CardSkeleton } from "../components/Skeleton"; import { STATUS_CONFIG, SESSION_STATUS_CONFIG, isAgentAwaitingInput, isSessionAwaitingInput, } from "../lib/types"; import type { Agent, AgentStatus, EffectiveAgentStatus, EffectiveSessionStatus, Session, WSMessage, } from "../lib/types"; type BoardView = "agents" | "sessions"; // Persisted statuses we fetch from the API. const AGENT_FETCH_STATUSES: AgentStatus[] = ["working", "waiting", "completed", "error"]; // Columns rendered on the Agents board. const AGENT_COLUMNS: EffectiveAgentStatus[] = ["working", "waiting", "completed", "error"]; const SESSION_COLUMNS: EffectiveSessionStatus[] = [ "active", "waiting", "completed", "error", "abandoned", ]; const COLUMN_PAGE_SIZE = 10; const VIEW_STORAGE_KEY = "kanban-board-view"; function loadView(): BoardView { try { const stored = localStorage.getItem(VIEW_STORAGE_KEY); if (stored === "agents" || stored === "sessions") return stored; } catch { /* ignore */ } return "agents"; } function persistView(view: BoardView): void { try { localStorage.setItem(VIEW_STORAGE_KEY, view); } catch { /* ignore */ } } export function KanbanBoard() { const { t } = useTranslation("kanban"); const [view, setViewState] = useState(loadView); const [agents, setAgents] = useState([]); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); const [expanded, setExpanded] = useState>({}); const setView = useCallback((next: BoardView) => { setViewState(next); persistView(next); setExpanded({}); // reset per-column pagination when switching views }, []); const loadAgents = useCallback(async () => { // Fetch every persisted agent status. Bucketing happens below in // `groupedAgents`. // // Also fetch sessions so AgentCard can surface model / cwd / cost on // main-agent cards (they have no task and a generic name on their // own - the session metadata is what makes the card useful). const [agentResults, sessionsRes] = await Promise.all([ Promise.all(AGENT_FETCH_STATUSES.map((status) => api.agents.list({ status }))), api.sessions.list({ limit: 10000 }), ]); setAgents(agentResults.flatMap((r) => r.agents)); setSessions(sessionsRes.sessions); }, []); const loadSessions = useCallback(async () => { // Each column needs the full set for its status - column-level // pagination ("show more") is handled client-side at COLUMN_PAGE_SIZE. // Wire-limit raised to the server's safety cap (10000); cost // computation on the server scales with returned rows, so each // column's request stays bounded by how many sessions actually have // that status. The "waiting" column is derived client-side from the // active set (see grouping below). const persistedStatuses = SESSION_COLUMNS.filter((s) => s !== "waiting"); const results = await Promise.all( persistedStatuses.map((status) => api.sessions.list({ status, limit: 10000 })) ); setSessions(results.flatMap((r) => r.sessions)); }, []); const load = useCallback(async () => { try { if (view === "agents") await loadAgents(); else await loadSessions(); } finally { setLoading(false); } }, [view, loadAgents, loadSessions]); useEffect(() => { setLoading(true); load(); }, [load]); useEffect(() => { let debounceTimer: ReturnType | null = null; return eventBus.subscribe((msg: WSMessage) => { if (isRemoteDataRefreshMessage(msg)) { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(load, 300); return; } if (view === "agents") { if ( msg.type === "agent_created" || msg.type === "agent_updated" || msg.type === "session_updated" || msg.type === "session_created" ) { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(loadAgents, 300); } } else { if (msg.type === "session_created" || msg.type === "session_updated") { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(loadSessions, 300); } } }); }, [view, loadAgents, loadSessions]); // Lookup map for AgentCard's session prop - memoized to avoid rebuilding on every render const sessionsById = useMemo(() => { const map = new Map(); for (const s of sessions) map.set(s.id, s); return map; }, [sessions]); // Bucket by effective status: agents with status "waiting" OR those with // awaiting_input_since set go into the "waiting" column. Other columns // exclude agents that belong in "waiting". const isEffectivelyWaiting = (a: Agent) => a.status === "waiting" || isAgentAwaitingInput(a); const groupedAgents = AGENT_COLUMNS.reduce( (acc, status) => { acc[status] = status === "waiting" ? agents.filter(isEffectivelyWaiting) : agents.filter((a) => a.status === status && !isEffectivelyWaiting(a)); return acc; }, {} as Record ); const groupedSessions = SESSION_COLUMNS.reduce( (acc, status) => { acc[status] = status === "waiting" ? sessions.filter(isSessionAwaitingInput) : sessions.filter((s) => s.status === status && !isSessionAwaitingInput(s)); return acc; }, {} as Record ); const total = view === "agents" ? agents.length : sessions.length; const subtitle = view === "agents" ? t("agentCount", { count: agents.length }) : t("sessionCount", { count: sessions.length }); const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected); const Header = (

{t("title")}

{wsConnected ? ( {t("common:live")} ) : ( {t("common:offline")} )}

{subtitle}

); if (!loading && total === 0) { return (
{Header}
{t("common:refresh")} } />
); } return (
{Header}
{view === "agents" ? AGENT_COLUMNS.map((status) => { const config = STATUS_CONFIG[status]; const items = groupedAgents[status]; const limit = expanded[status] || COLUMN_PAGE_SIZE; return ( setExpanded((prev) => ({ ...prev, [status]: limit + COLUMN_PAGE_SIZE, })) } > {loading && (items?.length ?? 0) === 0 ? Array.from({ length: 3 }).map((_, i) => ( )) : items ?.slice(0, limit) .map((agent) => ( ))} ); }) : SESSION_COLUMNS.map((status) => { const config = SESSION_STATUS_CONFIG[status]; const items = groupedSessions[status]; const limit = expanded[status] || COLUMN_PAGE_SIZE; return ( setExpanded((prev) => ({ ...prev, [status]: limit + COLUMN_PAGE_SIZE, })) } > {loading && (items?.length ?? 0) === 0 ? Array.from({ length: 3 }).map((_, i) => ( )) : items ?.slice(0, limit) .map((session) => )} ); })}
); } interface ViewToggleProps { view: BoardView; onChange: (next: BoardView) => void; } function ViewToggle({ view, onChange }: ViewToggleProps) { const { t } = useTranslation("kanban"); const baseClass = "px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-lg last:rounded-r-lg"; const activeClass = "bg-accent/15 text-accent"; const inactiveClass = "text-fg-secondary hover:text-fg-primary hover:bg-surface-3"; return (
); } interface ColumnProps { labelKey: string; color: string; dotClass: string; pulse: boolean; count: number; emptyLabel: string; /** Multi-line description rendered in a tooltip when the user hovers * the column's help icon. Pass an empty string to suppress the icon. */ tooltip?: string; remaining: number; onShowMore: () => void; children: React.ReactNode; } function Column({ labelKey, color, dotClass, pulse, count, emptyLabel, tooltip, remaining, onShowMore, children, }: ColumnProps) { const { t } = useTranslation("kanban"); const childrenArray = Array.isArray(children) ? children : children ? [children] : []; const hasChildren = childrenArray.length > 0; return (
{t(labelKey)} {tooltip && } {count}
{hasChildren ? ( <> {children} {remaining > 0 && ( )} ) : (
{emptyLabel}
)}
); } /** * Help icon + tooltip for a Kanban column header. Hover or focus shows a * multi-line description explaining what the column lists and what the * status means in lifecycle terms. Keyboard-focusable for accessibility. */ function ColumnHelp({ text }: { text: string }) { const [show, setShow] = useState(false); // Anchor positioning to the column header so the tooltip stays in-page on // the leftmost columns (where a centered tooltip would clip on narrow // viewports). We always anchor left-aligned to the trigger. const triggerRef = useRef(null); return ( setShow(true)} onMouseLeave={() => setShow(false)} onFocus={() => setShow(true)} onBlur={() => setShow(false)} > {show && ( {text} )} ); }