b673363351
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.
- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
fg.primary/secondary/muted, status.success/danger/warning. One class flip
on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
client/src onto the new tokens, so every badge/button/component pulls the
same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
constants — slate/blue/green/red/amber steps 1-12 — adopted after three
rounds of hand-picked values that kept overshooting (flat, then too dark,
then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
visual language (border + text + translucent wash of the same status
color); `current` alone stays a solid accent fill, the one state that
gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
Workspace lane-detail header already showing them.
Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
531 lines
19 KiB
TypeScript
531 lines
19 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* 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<BoardView>(loadView);
|
|
const [agents, setAgents] = useState<Agent[]>([]);
|
|
const [sessions, setSessions] = useState<Session[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [expanded, setExpanded] = useState<Record<string, number>>({});
|
|
|
|
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<typeof setTimeout> | 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<string, Session>();
|
|
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<EffectiveAgentStatus, Agent[]>
|
|
);
|
|
|
|
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<EffectiveSessionStatus, Session[]>
|
|
);
|
|
|
|
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 = (
|
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
|
<Columns3 className="w-4.5 h-4.5 text-accent" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold text-fg-primary truncate">{t("title")}</h1>
|
|
{wsConnected ? (
|
|
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
|
|
{t("common:live")}
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
|
|
{t("common:offline")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-fg-muted truncate">{subtitle}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<ViewToggle view={view} onChange={setView} />
|
|
<button onClick={load} className="btn-ghost flex-shrink-0">
|
|
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (!loading && total === 0) {
|
|
return (
|
|
<div className="animate-fade-in flex flex-col min-h-[60vh]">
|
|
{Header}
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<EmptyState
|
|
icon={Columns3}
|
|
title={view === "agents" ? t("noAgents") : t("noSessions")}
|
|
description={view === "agents" ? t("noAgentsDesc") : t("noSessionsDesc")}
|
|
action={
|
|
<button onClick={load} className="btn-primary">
|
|
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
|
|
</button>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="animate-fade-in">
|
|
{Header}
|
|
|
|
<div className="flex gap-4 min-h-[600px] overflow-x-auto pb-4 -mx-8 px-8">
|
|
{view === "agents"
|
|
? AGENT_COLUMNS.map((status) => {
|
|
const config = STATUS_CONFIG[status];
|
|
const items = groupedAgents[status];
|
|
const limit = expanded[status] || COLUMN_PAGE_SIZE;
|
|
return (
|
|
<Column
|
|
key={status}
|
|
labelKey={config.labelKey}
|
|
color={config.color}
|
|
dotClass={config.dot}
|
|
pulse={status === "working" || status === "waiting"}
|
|
count={items?.length ?? 0}
|
|
emptyLabel={t("noAgentsInColumn")}
|
|
tooltip={t(`tooltip.agent.${status}`)}
|
|
remaining={Math.max(0, (items?.length ?? 0) - limit)}
|
|
onShowMore={() =>
|
|
setExpanded((prev) => ({
|
|
...prev,
|
|
[status]: limit + COLUMN_PAGE_SIZE,
|
|
}))
|
|
}
|
|
>
|
|
{loading && (items?.length ?? 0) === 0
|
|
? Array.from({ length: 3 }).map((_, i) => (
|
|
<CardSkeleton key={`sk-${status}-${i}`} />
|
|
))
|
|
: items
|
|
?.slice(0, limit)
|
|
.map((agent) => (
|
|
<AgentCard
|
|
key={agent.id}
|
|
agent={agent}
|
|
session={sessionsById.get(agent.session_id)}
|
|
/>
|
|
))}
|
|
</Column>
|
|
);
|
|
})
|
|
: SESSION_COLUMNS.map((status) => {
|
|
const config = SESSION_STATUS_CONFIG[status];
|
|
const items = groupedSessions[status];
|
|
const limit = expanded[status] || COLUMN_PAGE_SIZE;
|
|
return (
|
|
<Column
|
|
key={status}
|
|
labelKey={config.labelKey}
|
|
color={config.color}
|
|
dotClass={config.dot}
|
|
pulse={status === "active" || status === "waiting"}
|
|
count={items?.length ?? 0}
|
|
emptyLabel={t("noSessionsInColumn")}
|
|
tooltip={t(`tooltip.session.${status}`)}
|
|
remaining={Math.max(0, (items?.length ?? 0) - limit)}
|
|
onShowMore={() =>
|
|
setExpanded((prev) => ({
|
|
...prev,
|
|
[status]: limit + COLUMN_PAGE_SIZE,
|
|
}))
|
|
}
|
|
>
|
|
{loading && (items?.length ?? 0) === 0
|
|
? Array.from({ length: 3 }).map((_, i) => (
|
|
<CardSkeleton key={`sk-${status}-${i}`} />
|
|
))
|
|
: items
|
|
?.slice(0, limit)
|
|
.map((session) => <SessionCard key={session.id} session={session} />)}
|
|
</Column>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
role="tablist"
|
|
aria-label={t("viewToggle.agents") + " / " + t("viewToggle.sessions")}
|
|
className="inline-flex border border-border rounded-lg overflow-hidden bg-surface-2"
|
|
>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={view === "agents"}
|
|
onClick={() => onChange("agents")}
|
|
className={`${baseClass} ${view === "agents" ? activeClass : inactiveClass}`}
|
|
>
|
|
{t("viewToggle.agents")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={view === "sessions"}
|
|
onClick={() => onChange("sessions")}
|
|
className={`${baseClass} border-l border-border ${
|
|
view === "sessions" ? activeClass : inactiveClass
|
|
}`}
|
|
>
|
|
{t("viewToggle.sessions")}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="bg-surface-1 rounded-xl border border-border p-3 flex flex-col flex-shrink-0 w-72">
|
|
<div className="flex items-center gap-2 mb-4 px-1">
|
|
<span className={`w-2 h-2 rounded-full ${dotClass} ${pulse ? "animate-pulse-dot" : ""}`} />
|
|
<span className={`text-xs font-semibold uppercase tracking-wider ${color}`}>
|
|
{t(labelKey)}
|
|
</span>
|
|
{tooltip && <ColumnHelp text={tooltip} />}
|
|
<span className="ml-auto text-[11px] text-fg-muted bg-surface-3 px-2 py-0.5 rounded-full">
|
|
{count}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-2.5 overflow-y-auto">
|
|
{hasChildren ? (
|
|
<>
|
|
{children}
|
|
{remaining > 0 && (
|
|
<button
|
|
onClick={onShowMore}
|
|
className="w-full py-2 text-[11px] text-fg-muted hover:text-fg-secondary flex items-center justify-center gap-1 transition-colors"
|
|
>
|
|
<ChevronDown className="w-3 h-3" />
|
|
{t("common:showMore", { count: remaining })}
|
|
</button>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center h-24 text-xs text-fg-muted">
|
|
{emptyLabel}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<HTMLSpanElement>(null);
|
|
|
|
return (
|
|
<span
|
|
ref={triggerRef}
|
|
className="relative inline-flex items-center cursor-help"
|
|
tabIndex={0}
|
|
role="img"
|
|
aria-label={text}
|
|
onMouseEnter={() => setShow(true)}
|
|
onMouseLeave={() => setShow(false)}
|
|
onFocus={() => setShow(true)}
|
|
onBlur={() => setShow(false)}
|
|
>
|
|
<HelpCircle className="w-3 h-3 text-fg-muted hover:text-fg-secondary transition-colors" />
|
|
{show && (
|
|
<span
|
|
role="tooltip"
|
|
className="absolute left-0 top-full mt-1.5 w-64 px-3 py-2 text-[11px] leading-relaxed text-fg-secondary bg-surface-3 border border-border rounded-md shadow-xl z-50 pointer-events-none whitespace-pre-line"
|
|
>
|
|
{text}
|
|
</span>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|