feat: Claude Code Monitor — lanes, pipelines and a merged workspace

Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
+589
View File
@@ -0,0 +1,589 @@
/**
* @file ActivityFeed.tsx
* @description Real-time feed of agent events with server-driven filters and
* batched pagination. Clicking a row toggles the inline EventDetail payload
* view; the "View session" Link navigates to the session page. Live events
* trigger a debounced, filter-aware refetch that preserves the user's
* accumulated 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`
* - `../lib/dataScope`
* - `../components/StatusBadge`
* - `../components/EmptyState`
* - `../components/EventDetail`
* - `../components/EventFilters`
* - `../components/EventFiltersInfo`
* - `../components/Skeleton`
* - `../lib/event-grouping`
* - `../lib/format`
* - `../lib/types`
*
* ## Public surface
* - `ActivityFeed` — 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).
* -----------------------------------------------------------------------------
* **ActivityFeed**
* 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, useRef, useMemo, useSyncExternalStore } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Activity, Pause, Play, RefreshCw, ChevronRight, ExternalLink } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { AgentStatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { EventDetail } from "../components/EventDetail";
import {
EventFilters,
EMPTY_FILTERS,
isEmptyFilters,
expandStatusToEventTypes,
} from "../components/EventFilters";
import type { EventFiltersValue } from "../components/EventFilters";
import { EventFiltersInfo } from "../components/EventFiltersInfo";
import { Skeleton } from "../components/Skeleton";
import {
agentOriginLabel,
buildEventTitle,
buildOriginLabel,
projectFromCwd,
projectFromEvent,
statusFromEventType,
} from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import { formatTime, formatDateShort, timeAgo } from "../lib/format";
import type { DashboardEvent } from "../lib/types";
const PAGE_SIZE = 50;
// Max rows a single /api/events request can return (server cap). Refreshes
// triggered by live events are bounded by this.
const MAX_REFRESH = 500;
// Debounce live-event refreshes so a burst of hook events (e.g. a stream of
// PostToolUse results) triggers one refetch instead of dozens.
const REFRESH_DEBOUNCE_MS = 500;
export function ActivityFeed() {
const { t } = useTranslation("activity");
const [events, setEvents] = useState<DashboardEvent[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [filters, setFilters] = useState<EventFiltersValue>(EMPTY_FILTERS);
const [paused, setPaused] = useState(false);
const [loading, setLoading] = useState(true);
const [bufferCount, setBufferCount] = useState(0);
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
// session_id → session name. Populated from /api/sessions on mount so rows
// can render a friendly session pill instead of a bare UUID.
const [sessionNameById, setSessionNameById] = useState<Map<string, string>>(() => new Map());
// session_id → project (basename of the session's cwd). Used as the leading
// origin segment for events whose own payload carries no cwd (e.g.
// TurnDuration "Turn completed" rows), so they match tool-use rows.
const [sessionProjectById, setSessionProjectById] = useState<Map<string, string>>(
() => new Map()
);
// agent_id → subagent-facing info. Populated from /api/agents on mount so the
// subagent pill can show subagent_type (e.g. "frontend-reviewer") instead of
// a raw ID. Main agents intentionally yield no pill.
const [agentInfoById, setAgentInfoById] = useState<Map<string, AgentInfo>>(() => new Map());
const bufferRef = useRef<DashboardEvent[]>([]);
const pausedRef = useRef(paused);
// Refs let the websocket handler read the latest filter/page without
// re-subscribing on every change.
const apiParamsRef = useRef<Record<string, unknown>>({});
const pageRef = useRef(0);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
pausedRef.current = paused;
pageRef.current = page;
function toggleEvent(id: number) {
setExpandedEvents((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
// Convert UI filter state → API params. Status presets expand into
// event_type values and merge with any explicit event_type selection.
const apiParams = useMemo(() => {
const statusExpanded = expandStatusToEventTypes(filters.status);
const eventTypeMerged = Array.from(new Set<string>([...filters.event_type, ...statusExpanded]));
return {
event_type: eventTypeMerged.length > 0 ? eventTypeMerged : undefined,
tool_name: filters.tool_name.length > 0 ? filters.tool_name : undefined,
agent_id: filters.agent_id.length > 0 ? filters.agent_id : undefined,
session_id: filters.session_id.length > 0 ? filters.session_id : undefined,
q: filters.q || undefined,
from: filters.from ? new Date(filters.from).toISOString() : undefined,
to: filters.to ? new Date(filters.to).toISOString() : undefined,
};
}, [filters]);
apiParamsRef.current = apiParams;
// Global data scope; a change re-runs `load` (api injects the `sources` param).
const [scope] = useDataScope();
const load = useCallback(async () => {
setLoading(true);
try {
const { events: data, total: totalCount } = await api.events.list({
...apiParams,
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
});
setEvents(data);
setTotal(totalCount);
} catch (err) {
// Non-fatal: keep the previous list; log so dev tools surface the
// failure instead of raising an unhandled promise rejection.
console.error("Failed to load events:", err);
} finally {
setLoading(false);
}
}, [apiParams, page, scope]);
useEffect(() => {
load();
}, [load]);
// Reset to page 0 whenever filters change so the user lands on the first
// page of the new filtered result set.
useEffect(() => {
setPage(0);
}, [apiParams]);
useEffect(() => {
let cancelled = false;
// Mount-time name lookups: ask for the server's safety cap so even
// long-running deployments get full pill coverage. These are one-shot
// fetches; cost computation is bounded by returned rows but agents
// don't have it and sessions only compute it on the page-sized list,
// so this is cheap.
api.sessions
.list({ limit: 10000 })
.then(({ sessions }) => {
if (cancelled) return;
const map = new Map<string, string>();
const projectMap = new Map<string, string>();
for (const s of sessions) {
// Always populate so the EventDetail panel's "Session" row shows
// an identifiable label even for unnamed sessions. Matches the
// fallback used by SessionDetail's header.
const label = s.name?.trim() || `Session ${s.id.slice(0, 8)}`;
map.set(s.id, label);
const project = projectFromCwd(s.cwd);
if (project) projectMap.set(s.id, project);
}
setSessionNameById(map);
setSessionProjectById(projectMap);
})
.catch(() => {
// Non-fatal: rows just render without the session name pill.
});
api.agents
.list({ limit: 10000 })
.then(({ agents }) => {
if (cancelled) return;
const map = new Map<string, AgentInfo>();
for (const a of agents) {
map.set(a.id, {
type: a.type,
subagent_type: a.subagent_type,
name: a.name,
parent_agent_id: a.parent_agent_id,
});
}
setAgentInfoById(map);
})
.catch(() => {
// Non-fatal: subagent pills fall back to the short-id label.
});
return () => {
cancelled = true;
};
}, []);
// Refetches the current page using the latest filter state. Capped at
// MAX_REFRESH (server limit); at the default PAGE_SIZE this is a no-op cap.
const refreshWithPagination = useCallback(async () => {
const size = Math.min(PAGE_SIZE, MAX_REFRESH);
try {
const { events: data, total: totalCount } = await api.events.list({
...apiParamsRef.current,
limit: size,
offset: pageRef.current * PAGE_SIZE,
});
setEvents(data);
setTotal(totalCount);
} catch (err) {
// Non-fatal: swallow the error so a flaky websocket burst doesn't
// spam unhandled rejections; the next live event / manual refresh
// will try again.
console.error("Failed to refresh events:", err);
}
}, []);
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg) => {
// A remote source finished syncing → new remote events may exist; do a
// filter-aware refresh (respects pause via refreshWithPagination's guard).
if (isRemoteDataRefreshMessage(msg)) {
if (!pausedRef.current) refreshWithPagination();
return;
}
if (msg.type !== "new_event") return;
const event = msg.data as DashboardEvent;
if (pausedRef.current) {
bufferRef.current = [event, ...bufferRef.current];
setBufferCount(bufferRef.current.length);
return;
}
// Debounce bursts into a single filter-aware refresh.
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
refreshWithPagination();
}, REFRESH_DEBOUNCE_MS);
});
return () => {
unsubscribe();
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
};
}, [refreshWithPagination]);
function resume() {
pausedRef.current = false;
bufferRef.current = [];
setBufferCount(0);
setPaused(false);
// Catch-up via filtered refresh so buffered non-matching events don't leak in.
refreshWithPagination();
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// Precompute the project name per event so row rendering doesn't re-parse
// event.data JSON on every render pass.
const projectByEventId = useMemo(() => {
const map = new Map<number, string | null>();
for (const e of events) map.set(e.id, projectFromEvent(e));
return map;
}, [events]);
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
return (
<div className="animate-fade-in">
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<Activity className="w-4.5 h-4.5 text-accent" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
{t("common:live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-gray-500">
{t("subtitle")}
{paused && (
<span className="ml-2 text-yellow-400">{t("paused", { count: bufferCount })}</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => (paused ? resume() : setPaused(true))} className="btn-ghost">
{paused ? (
<>
<Play className="w-4 h-4" /> {t("resume")}
</>
) : (
<>
<Pause className="w-4 h-4" /> {t("pause")}
</>
)}
</button>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
<div className="mb-3">
<EventFiltersInfo />
</div>
<div className="mb-4">
<EventFilters
value={filters}
onChange={setFilters}
sessionOptions={Array.from(sessionNameById.entries()).map(([id, label]) => ({
id,
label,
}))}
/>
</div>
{!loading && events.length === 0 ? (
<EmptyState
icon={Activity}
title={isEmptyFilters(filters) ? t("noActivity") : t("common:eventFilters.noResults")}
description={isEmptyFilters(filters) ? t("noActivityDesc") : ""}
/>
) : (
<>
<div className="card overflow-hidden">
<div className="divide-y divide-border max-h-[calc(100vh-260px)] min-h-[560px] overflow-y-auto overflow-x-auto">
{loading && events.length === 0
? Array.from({ length: 12 }).map((_, i) => (
<div
key={`sk-${i}`}
className="flex items-center px-5 py-3.5 gap-4"
aria-busy="true"
>
<Skeleton className="w-3.5 h-3.5" rounded="sm" />
<Skeleton className="h-3 w-14" />
<Skeleton className="h-5 w-16" rounded="full" />
<Skeleton className="h-3 w-48 flex-shrink-0" />
<Skeleton className="h-3 flex-1" />
</div>
))
: null}
{events.map((event, i) => {
const isOpen = event.id != null && expandedEvents.has(event.id);
return (
<div key={event.id ?? i} className="animate-slide-up">
<div
role="button"
tabIndex={0}
onClick={() => {
if (event.id != null) toggleEvent(event.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (event.id != null) toggleEvent(event.id);
}
}}
aria-expanded={isOpen}
className="flex items-center px-5 py-3.5 gap-4 hover:bg-surface-4 transition-colors cursor-pointer select-none"
>
<ChevronRight
className={`w-3.5 h-3.5 text-gray-500 transition-transform flex-shrink-0 -mr-1.5 ${isOpen ? "rotate-90" : ""}`}
/>
<div className="w-16 flex-shrink-0 text-right font-mono leading-tight">
<div className="text-[11px] text-gray-500">
{formatTime(event.created_at)}
</div>
<div className="text-[9px] text-gray-600">
{formatDateShort(event.created_at)}
</div>
</div>
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
{(() => {
const sname = sessionNameById.get(event.session_id);
const project =
projectByEventId.get(event.id) ??
sessionProjectById.get(event.session_id) ??
null;
const origin = buildOriginLabel(
project,
sname ?? null,
agentOriginLabel(event.agent_id, agentInfoById)
);
return (
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-300 truncate">
{origin && (
<span
className="text-gray-500 mr-1"
title={`${event.session_id} · ${event.agent_id ?? ""}`}
>
{origin} ·
</span>
)}
{buildEventTitle(event)}
</p>
</div>
);
})()}
{event.tool_name && (
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-gray-500 font-mono flex-shrink-0">
{event.tool_name}
</span>
)}
<span className="text-[11px] text-gray-600 flex-shrink-0 w-16 text-right">
{timeAgo(event.created_at)}
</span>
<Link
to={`/sessions/${event.session_id}`}
onClick={(e) => e.stopPropagation()}
title={t("viewSession")}
className="flex items-center gap-1 text-[11px] px-2.5 py-1 rounded-md bg-surface-2 text-gray-400 hover:text-accent hover:bg-accent/10 border border-border hover:border-accent/30 transition-colors flex-shrink-0 font-medium"
>
{t("viewSession")}
<ExternalLink className="w-3 h-3" />
</Link>
</div>
{isOpen && <EventDetail event={event} />}
</div>
);
})}
</div>
</div>
{total > 0 && (
<div className="flex items-center justify-between mt-4 px-1">
<span className="text-xs text-gray-500">
{t("common:pagination.showing", {
from: page * PAGE_SIZE + 1,
to: Math.min((page + 1) * PAGE_SIZE, total),
total,
})}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(0)}
disabled={page === 0}
className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
aria-label="First page"
>
«
</button>
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{t("common:pagination.previous")}
</button>
{(() => {
// Compact numbered page buttons: show up to 5 pages around
// the current page, with ellipses when appropriate.
const pages: (number | "...")[] = [];
const windowSize = 5;
let start = Math.max(0, page - Math.floor(windowSize / 2));
let end = Math.min(totalPages - 1, start + windowSize - 1);
start = Math.max(0, Math.min(start, end - windowSize + 1));
if (start > 0) {
pages.push(0);
if (start > 1) pages.push("...");
}
for (let i = start; i <= end; i++) pages.push(i);
if (end < totalPages - 1) {
if (end < totalPages - 2) pages.push("...");
pages.push(totalPages - 1);
}
return pages.map((p, idx) =>
p === "..." ? (
<span
key={`ellipsis-${idx}`}
className="px-2 py-1.5 text-xs text-gray-600 select-none"
>
...
</span>
) : (
<button
key={p}
onClick={() => setPage(p)}
aria-current={p === page ? "page" : undefined}
className={`min-w-[32px] px-2.5 py-1.5 text-xs font-medium rounded-md cursor-pointer transition-colors ${
p === page
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-2 text-gray-400 hover:text-gray-200"
}`}
>
{p + 1}
</button>
)
);
})()}
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{t("common:pagination.next")}
</button>
<button
onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1}
className="px-2 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
aria-label="Last page"
>
»
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+530
View File
@@ -0,0 +1,530 @@
/**
* @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-gray-100 truncate">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
{t("common:live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-gray-500 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-gray-400 hover:text-gray-200 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-gray-600 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-gray-500 hover:text-gray-300 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-gray-600">
{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-gray-500 hover:text-gray-300 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-gray-200 bg-surface-3 border border-border rounded-md shadow-xl z-50 pointer-events-none whitespace-pre-line"
>
{text}
</span>
)}
</span>
);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* @file NotFound.tsx
* @description Catch-all route for unknown paths (`path="*"` in {@link App}).
* Presents a friendly 404 card with translated copy and two recovery actions:
* navigate home (dashboard) or go back one history entry.
*
* Uses the `errors` i18n namespace (`notFound.*` keys) so the page stays
* localized without hard-coded English strings.
*
* @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`.
*
* ## Public surface
* - `NotFound` — 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).
* -----------------------------------------------------------------------------
* **NotFound**
* 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 { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowLeft, Home } from "lucide-react";
/**
* 404 page rendered for unmatched routes.
* @returns Centered error card with navigation actions.
*/
export function NotFound() {
const navigate = useNavigate();
const { t } = useTranslation("errors");
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center animate-fade-in">
<div className="card max-w-xl w-full p-8 md:p-10 text-center">
<div className="w-14 h-14 mx-auto mb-5 rounded-xl bg-accent/15 border border-accent/25 flex items-center justify-center">
<AlertTriangle className="w-7 h-7 text-accent" />
</div>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 mb-2">
{t("notFound.code")}
</p>
<h2 className="text-2xl font-semibold text-gray-100 mb-2">{t("notFound.title")}</h2>
<p className="text-sm text-gray-400 mb-8">{t("notFound.description")}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<button className="btn-primary" onClick={() => navigate("/")}>
<Home className="w-4 h-4" />
{t("notFound.goDashboard")}
</button>
<button
className="btn-ghost border border-border hover:border-border-light"
onClick={() => navigate(-1)}
>
<ArrowLeft className="w-4 h-4" />
{t("notFound.goBack")}
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+537
View File
@@ -0,0 +1,537 @@
/**
* @file Sessions.tsx
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features. Sessions are updated in real-time based on events received from the event bus.
* @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`
* - `../lib/dataScope`
* - `../components/StatusBadge`
* - `../components/EmptyState`
* - `../components/Skeleton`
* - `../lib/format`
* - `../lib/types`
*
* ## Public surface
* - `Sessions` — 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).
* -----------------------------------------------------------------------------
* **Sessions**
* 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, useSyncExternalStore } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
FolderOpen,
Search,
ChevronRight,
RefreshCw,
SortDesc,
SortAsc,
ChevronDown,
Play,
Server,
} from "lucide-react";
import { api } from "../lib/api";
import type { RemoteSource } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { SessionStatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { TableRowSkeleton } from "../components/Skeleton";
import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
import {
effectiveSessionStatus,
isSessionAwaitingInput,
sessionAwaitingReason,
} from "../lib/types";
import type { Session, DashboardEvent } from "../lib/types";
const PAGE_SIZE = 10;
export function Sessions() {
const navigate = useNavigate();
const { t } = useTranslation("sessions");
const [sessions, setSessions] = useState<Session[]>([]);
const [total, setTotal] = useState(0);
const [filter, setFilter] = useState("");
// `searchInput` is what the user types; `search` is the debounced value
// actually sent to the server. Without debouncing, every keystroke would
// hit /api/sessions.
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const [cwd, setCwd] = useState("");
const [sortBy, setSortBy] = useState("time");
const [sortDesc, setSortDesc] = useState(true);
const [directories, setDirectories] = useState<string[]>([]);
// Global data scope (which source machines to show). Included in `load`'s deps
// so switching scope re-fetches; the actual `sources` param is injected by the
// api layer (see lib/api.ts applyScope).
const [scope] = useDataScope();
// source id → label, so remote-origin rows show a friendly badge.
const [sourceLabels, setSourceLabels] = useState<Map<string, string>>(() => new Map());
// Set of session IDs that are currently being driven by an in-flight Run
// handle on /run. Lets us badge those rows with a "Run" link.
const [dashboardRunIds, setDashboardRunIds] = useState<Set<string>>(new Set());
const FILTER_OPTIONS: Array<{ label: string; value: string }> = [
{ label: t("filterAll"), value: "" },
{ label: t("filterActive"), value: "active" },
{ label: t("filterWaiting"), value: "waiting" },
{ label: t("filterCompleted"), value: "completed" },
{ label: t("filterError"), value: "error" },
{ label: t("filterAbandoned"), value: "abandoned" },
];
// Debounce the search input → 300 ms after the user stops typing, the
// committed value flips and triggers a fresh fetch.
useEffect(() => {
const id = window.setTimeout(() => setSearch(searchInput.trim()), 300);
return () => window.clearTimeout(id);
}, [searchInput]);
useEffect(() => {
api.sessions
.facets()
.then((res) => {
setDirectories(res.cwds);
})
.catch(console.error);
}, []);
// Load remote-source labels so remote-origin rows can show a friendly badge
// instead of a raw `src_…` id. Refreshed when a source's status changes.
const loadSourceLabels = useCallback(() => {
api.remoteSources
.list()
.then((res: { sources: RemoteSource[] }) => {
setSourceLabels(new Map(res.sources.map((s) => [s.id, s.label])));
})
.catch(() => undefined);
}, []);
useEffect(() => {
loadSourceLabels();
}, [loadSourceLabels]);
// Server-side pagination: only the visible page is fetched. Cost
// computation on the server scales with PAGE_SIZE, not with the total
// session count, so this stays cheap regardless of how many sessions
// exist in the database.
const load = useCallback(async () => {
try {
// The "waiting" filter is a UI-only overlay derived from the
// awaiting_input_since column - the underlying SessionStatus is
// still "active". Map it to a client-side filter on top of the
// active set so paging/totals stay consistent with the visible rows.
if (filter === "waiting") {
const res = await api.sessions.list({
status: "active",
q: search || undefined,
cwd: cwd || undefined,
sort_by: sortBy,
sort_desc: sortDesc,
limit: 10000,
offset: 0,
});
const waiting = res.sessions.filter(isSessionAwaitingInput);
setTotal(waiting.length);
setSessions(waiting.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE));
return;
}
const params: {
status?: string;
q?: string;
cwd?: string;
sort_by?: string;
sort_desc?: boolean;
limit: number;
offset: number;
} = {
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
sort_by: sortBy,
sort_desc: sortDesc,
};
if (filter) params.status = filter;
if (search) params.q = search;
if (cwd) params.cwd = cwd;
const res = await api.sessions.list(params);
setSessions(res.sessions);
setTotal(res.total);
} finally {
setLoading(false);
}
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
// the matching `sources` param.
}, [filter, search, cwd, sortBy, sortDesc, page, scope]);
useEffect(() => {
load();
}, [load]);
// Reset to page 0 whenever filters or sort changes.
useEffect(() => {
setPage(0);
}, [filter, search, cwd, sortBy, sortDesc]);
useEffect(() => {
return eventBus.subscribe((msg) => {
if (msg.type === "session_created" || msg.type === "session_updated") {
load();
}
if (msg.type === "new_event") {
const ev = msg.data as DashboardEvent;
if (ev.event_type === "Stop" || ev.event_type === "SessionEnd") {
load();
}
}
if (msg.type === "run_status") {
loadDashboardRuns();
}
// A remote source finished syncing: new remote sessions may have landed.
if (msg.type === "remote_source.status") {
loadSourceLabels();
if (isRemoteDataRefreshMessage(msg)) load();
} else if (isRemoteDataRefreshMessage(msg)) {
load();
}
});
// loadDashboardRuns is a stable useCallback declared below; referenced at
// event time only (not in deps) to avoid a temporal-dead-zone at render.
}, [load, loadSourceLabels]);
// Pull active Run handles so we can mark which sessions are being driven
// from /run right now. Refresh on mount, on run_status WS messages, and
// every 15s as a safety net for stale browser state.
const loadDashboardRuns = useCallback(() => {
api.run
.list()
.then((r) => {
const ids = new Set<string>();
for (const h of r.items) {
if (h.sessionId) ids.add(h.sessionId);
}
setDashboardRunIds(ids);
})
.catch(() => undefined);
}, []);
useEffect(() => {
loadDashboardRuns();
const t = setInterval(loadDashboardRuns, 15000);
return () => clearInterval(t);
}, [loadDashboardRuns]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// The server already paginates, so the rendered page IS the loaded list.
const paged = sessions;
const filtered = sessions; // kept for empty-state checks below
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
return (
<div className="animate-fade-in">
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<FolderOpen className="w-4.5 h-4.5 text-accent" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
{t("common:live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-gray-500">
{t("sessionCount", { count: total })}
{filter ? ` ${filter}` : ""}
</p>
</div>
</div>
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap lg:flex-nowrap items-center gap-3 mb-6 bg-surface-2/40 p-2 rounded-xl border border-border w-full">
{/* Search */}
<div className="relative flex-1 min-w-[180px] max-w-[340px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder={t("searchPlaceholder")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="input w-full pl-10"
/>
</div>
{/* Directory Selector */}
<div className="relative shrink-0 w-[180px]">
<select
value={cwd}
onChange={(e) => setCwd(e.target.value)}
className="input w-full text-ellipsis bg-surface-1 pr-9 appearance-none cursor-pointer"
>
<option value="">All Directories</option>
{directories.map((d) => (
<option key={d} value={d} title={d}>
{truncate(d, 30)}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none" />
</div>
{/* Sort Controls */}
<div className="flex items-center gap-1.5 bg-surface-1 px-1.5 py-1 rounded-lg border border-border h-[38px] flex-1 min-w-[180px]">
<div className="relative flex-1">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="bg-transparent w-full text-sm text-gray-200 outline-none pl-3 pr-8 appearance-none cursor-pointer whitespace-nowrap"
>
<option value="time">Sort by Time ({sortDesc ? "Newest" : "Oldest"})</option>
<option value="duration">
Sort by Duration ({sortDesc ? "Longest" : "Shortest"})
</option>
<option value="price">Sort by Price ({sortDesc ? "Highest" : "Lowest"})</option>
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-500 pointer-events-none" />
</div>
<div className="w-px h-4 bg-border mx-1" />
<button
onClick={() => setSortDesc(!sortDesc)}
className="p-1.5 rounded hover:bg-surface-3 text-gray-400 hover:text-gray-200 transition-colors shrink-0"
title={sortDesc ? "Descending" : "Ascending"}
>
{sortDesc ? <SortDesc className="w-4 h-4" /> : <SortAsc className="w-4 h-4" />}
</button>
</div>
{/* Status Filters */}
<div className="flex gap-1 bg-surface-1 rounded-lg p-1 border border-border ml-auto shrink-0">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setFilter(opt.value)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors whitespace-nowrap ${
filter === opt.value
? "bg-surface-4 text-gray-200"
: "text-gray-500 hover:text-gray-300"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{!loading && filtered.length === 0 ? (
<EmptyState
icon={FolderOpen}
title={t("noSessions")}
description={search || filter || cwd ? t("noSessionsDesc") : t("noSessionsHint")}
/>
) : (
<>
<div className="card overflow-x-auto">
<table className="w-full min-w-[800px]">
<thead>
<tr className="border-b border-border text-left">
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider">
{t("tableSession")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider">
{t("tableStatus")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider">
{t("tableLastActive")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider">
{t("tableDuration")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider">
{t("tableAgents")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider">
{t("tableCost")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-gray-500 uppercase tracking-wider">
{t("tableDirectory")}
</th>
<th className="w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading && paged.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<TableRowSkeleton
key={`sk-${i}`}
columns={8}
widths={["w-40", "w-20", "w-28", "w-20", "w-10", "w-16", "w-44", "w-4"]}
/>
))
: null}
{paged.map((session) => (
<tr
key={session.id}
onClick={() => navigate(`/sessions/${session.id}`)}
className="hover:bg-surface-4 transition-colors cursor-pointer group"
>
<td className="px-5 py-4">
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-gray-200">
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</p>
{session.source && session.source !== "local" && (
<span
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
>
<Server className="w-2.5 h-2.5" />
{sourceLabels.get(session.source) || session.source}
</span>
)}
{dashboardRunIds.has(session.id) && (
<Link
to={`/run?session=${encodeURIComponent(session.id)}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-300 bg-emerald-500/10 border border-emerald-500/25 hover:bg-emerald-500/20 hover:text-emerald-200 px-1.5 py-0.5 rounded-full transition-colors"
title={t("dashboardRunBadge", "Driven by Run page · click to open")}
>
<Play className="w-2.5 h-2.5" />
{t("common:dashboardRun", "Run")}
</Link>
)}
</div>
<p className="text-[11px] text-gray-600 font-mono">
{session.id.slice(0, 12)}
</p>
</div>
</td>
<td className="px-5 py-4">
<SessionStatusBadge
status={effectiveSessionStatus(session)}
reason={sessionAwaitingReason(session)}
/>
</td>
<td className="px-5 py-4 text-sm text-gray-400">
{formatDateTime(session.last_activity || session.started_at)}
</td>
<td className="px-5 py-4 text-sm text-gray-400 font-mono">
{session.ended_at
? formatDuration(session.started_at, session.ended_at)
: t("common:running")}
</td>
<td className="px-5 py-4 text-sm text-gray-400">
{session.agent_count ?? "-"}
</td>
<td className="px-5 py-4 text-sm text-gray-400 font-mono">
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
</td>
<td
className="px-5 py-4 text-[11px] text-gray-500 font-mono"
title={session.cwd || undefined}
>
{session.cwd ? truncate(session.cwd, 30) : "-"}
</td>
<td className="px-3 py-4">
<ChevronRight className="w-4 h-4 text-gray-600 group-hover:text-gray-400 transition-colors" />
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 px-1">
<span className="text-xs text-gray-500">
{t("common:pagination.showing", {
from: page * PAGE_SIZE + 1,
to: Math.min((page + 1) * PAGE_SIZE, total),
total,
})}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed"
>
{t("common:pagination.previous")}
</button>
<span className="px-3 py-1.5 text-xs text-gray-500">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-surface-2 text-gray-400 hover:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed"
>
{t("common:pagination.next")}
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+605
View File
@@ -0,0 +1,605 @@
/**
* @file Workflows.tsx
* @description Displays comprehensive analytics on agent orchestration patterns, including DAGs of agent spawning, tool usage flows, collaboration networks, and session complexity metrics, with real-time updates and interactive filtering.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Workflow analytics visualization built on D3; consumes aggregated session/run metrics from the workflows API.
*
* ## 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`
* - `../lib/types`
* - `../components/workflows/WorkflowStats`
* - `../components/workflows/OrchestrationDAG`
* - `../components/workflows/ToolExecutionFlow`
* - `../components/workflows/AgentCollaborationNetwork`
* - `../components/workflows/SubagentEffectiveness`
* - `../components/workflows/WorkflowPatterns`
* - `../components/workflows/ModelDelegationFlow`
* - `../components/workflows/ErrorPropagationMap`
* - `../components/workflows/ConcurrencyTimeline`
*
* ## Public surface
* - `Workflows` — 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).
* -----------------------------------------------------------------------------
* **Workflows**
* 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,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useTranslation } from "react-i18next";
import { Workflow, RefreshCw, Download, AlertCircle, Info } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import type { WorkflowData, WSMessage } from "../lib/types";
import { WorkflowStats } from "../components/workflows/WorkflowStats";
import { OrchestrationDAG } from "../components/workflows/OrchestrationDAG";
import { ToolExecutionFlow } from "../components/workflows/ToolExecutionFlow";
import { AgentCollaborationNetwork } from "../components/workflows/AgentCollaborationNetwork";
import { SubagentEffectiveness } from "../components/workflows/SubagentEffectiveness";
import { WorkflowPatterns } from "../components/workflows/WorkflowPatterns";
import { ModelDelegationFlow } from "../components/workflows/ModelDelegationFlow";
import { ErrorPropagationMap } from "../components/workflows/ErrorPropagationMap";
import { ConcurrencyTimeline } from "../components/workflows/ConcurrencyTimeline";
import { SessionComplexityScatter } from "../components/workflows/SessionComplexityScatter";
import { CompactionImpact } from "../components/workflows/CompactionImpact";
import { SessionDrillIn } from "../components/workflows/SessionDrillIn";
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
type StatusFilter = "all" | "active" | "completed";
export function Workflows() {
const { t } = useTranslation("workflows");
const [data, setData] = useState<WorkflowData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchData = useCallback(async () => {
try {
setError(null);
const result = await api.workflows.get(statusFilter);
setData(result);
setLastUpdated(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : t("failedLoad"));
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
fetchData();
}, [fetchData]);
// Auto-refresh on WebSocket events
useEffect(() => {
let debounceTimer: ReturnType<typeof setTimeout>;
const handler = (_msg: WSMessage) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(fetchData, 3000);
};
const unsub = eventBus.subscribe(handler);
return () => {
unsub();
clearTimeout(debounceTimer);
};
}, [fetchData]);
const handleRefresh = () => {
setLoading(true);
fetchData();
};
const handleExport = () => {
if (!data) return;
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `workflows-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
if (loading && !data) {
return (
<div className="space-y-6">
<PageHeader
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
onExport={handleExport}
lastUpdated={null}
/>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-6 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="card h-24 animate-pulse bg-surface-2" />
))}
</div>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="card h-64 animate-pulse bg-surface-2" />
))}
</div>
);
}
if (error && !data) {
return (
<div className="space-y-6">
<PageHeader
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
onExport={handleExport}
lastUpdated={null}
/>
<div className="card flex flex-col items-center justify-center py-16 gap-4">
<AlertCircle className="w-10 h-10 text-red-400" />
<p className="text-red-400 text-sm">{error}</p>
<button onClick={handleRefresh} className="btn-primary text-sm">
{t("common:retry")}
</button>
</div>
</div>
);
}
if (!data) return null;
return (
<div className="space-y-6">
{/* Page Header */}
<PageHeader
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
onExport={handleExport}
lastUpdated={lastUpdated}
/>
{/* Stats Row */}
<WorkflowStats stats={data.stats} />
{/* Workflow-tool runs (issue #167) - fleets ingested from on-disk journals */}
<div className="card p-4 space-y-3">
<div>
<h2 className="text-sm font-semibold text-gray-200 flex items-center gap-2">
<Workflow className="w-4 h-4 text-violet-400" />
{t("runs.title")}
</h2>
<p className="text-xs text-gray-500 mt-0.5">{t("runs.subtitle")}</p>
</div>
<WorkflowRunsPanel statusFilter={statusFilter} />
</div>
{/* Section 1: Agent Orchestration DAG */}
<Section
number={1}
title={t("orchestration.title")}
subtitle={t("orchestration.subtitle")}
infoKey="orchestration"
>
<OrchestrationDAG
data={data.orchestration}
onNodeClick={setSelectedNode}
selectedNode={selectedNode}
/>
{selectedNode && (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-gray-500">{t("filteredBy")}</span>
<span className="badge bg-accent/15 text-accent border border-accent/20 text-xs">
{selectedNode}
</span>
<button
onClick={() => setSelectedNode(null)}
className="text-xs text-gray-500 hover:text-gray-300 underline"
>
{t("clearFilter")}
</button>
</div>
)}
</Section>
{/* Section 2: Tool Execution Flow */}
<Section
number={2}
title={t("toolFlow.title")}
subtitle={t("toolFlow.subtitle")}
infoKey="toolFlow"
>
<ToolExecutionFlow data={data.toolFlow} filterAgentType={selectedNode} />
</Section>
{/* Section 3: Agent Collaboration Network */}
<Section
number={3}
title={t("pipeline.title")}
subtitle={t("pipeline.subtitle")}
infoKey="pipeline"
>
<AgentCollaborationNetwork effectiveness={data.effectiveness} edges={data.cooccurrence} />
</Section>
{/* Section 4 + 5: Two Column */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Section
number={4}
title={t("effectiveness.title")}
subtitle={t("effectiveness.subtitle")}
infoKey="effectiveness"
>
<SubagentEffectiveness data={data.effectiveness} />
</Section>
<Section
number={5}
title={t("patterns.title")}
subtitle={t("patterns.subtitle")}
infoKey="patterns"
>
<WorkflowPatterns data={data.patterns} onPatternClick={() => {}} />
</Section>
</div>
{/* Section 6 + 7: Two Column */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Section
number={6}
title={t("modelDelegation.title")}
subtitle={t("modelDelegation.subtitle")}
infoKey="modelDelegation"
>
<ModelDelegationFlow data={data.modelDelegation} />
</Section>
<Section
number={7}
title={t("errorPropagation.title")}
subtitle={t("errorPropagation.subtitle")}
infoKey="errorPropagation"
>
<ErrorPropagationMap data={data.errorPropagation} />
</Section>
</div>
{/* Section 8: Agent Concurrency Timeline */}
<Section
number={8}
title={t("concurrency.title")}
subtitle={t("concurrency.subtitle")}
infoKey="concurrency"
>
<ConcurrencyTimeline data={data.concurrency} />
</Section>
{/* Section 9 + 10: Two Column */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Section
number={9}
title={t("complexity.title")}
subtitle={t("complexity.subtitle")}
infoKey="complexity"
>
<SessionComplexityScatter data={data.complexity} onSessionClick={setSelectedSessionId} />
</Section>
<Section
number={10}
title={t("compaction.title")}
subtitle={t("compaction.subtitle")}
infoKey="compaction"
>
<CompactionImpact data={data.compaction} />
</Section>
</div>
{/* Section 11: Session Drill-In */}
<Section
number={11}
title={t("drillIn.title")}
subtitle={t("drillIn.subtitle")}
infoKey="drillIn"
>
<SessionDrillIn
sessionId={selectedSessionId}
onClose={() => setSelectedSessionId(null)}
onSelectSession={(id) => setSelectedSessionId(id)}
/>
</Section>
</div>
);
}
// ── Section wrapper ──
function Section({
number,
title,
subtitle,
infoKey,
children,
}: {
number: number;
title: string;
subtitle: string;
/** Key under workflows.chartInfo.* - drives the structured popover content. */
infoKey: string;
children: React.ReactNode;
}) {
return (
// Flex column + a flex-1 card so that, when two Sections share a grid row
// (lg:grid-cols-2), the grid's default row-stretch reaches the card itself —
// otherwise a shorter chart (e.g. the Session Complexity Scatter) leaves its
// card shorter than a taller companion (Compaction Impact) in the same row.
<div className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 mb-3">
<div className="flex items-center gap-2.5 min-w-0">
<span className="w-5 h-5 rounded-md bg-accent/15 text-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0">
{number}
</span>
<h2 className="text-sm font-semibold text-gray-100">{title}</h2>
<ChartInfoPopover infoKey={infoKey} title={title} />
</div>
{/* Quick descriptor; the full explanation lives in the ⓘ popover, so we
keep this to a single clamped line (ellipsis + hover title) so a long
translation never wraps and unbalances the header row. */}
<span
className="hidden lg:block flex-shrink-0 max-w-[20rem] xl:max-w-sm truncate text-right text-[11px] text-gray-600"
title={subtitle}
>
{subtitle}
</span>
</div>
<div className="card p-4 flex-1">{children}</div>
</div>
);
}
/**
* Structured info popover for a Workflows chart section. Hover or focus the
* `i` icon to read three short paragraphs sourced from i18n:
*
* 1. What this shows - what data the chart visualizes
* 2. How to read it - visual encoding (axes, sizes, colors, etc.)
* 3. Why it matters - what insights the user can extract
*
* The popover uses fixed positioning and is clamped to the viewport so it
* never gets clipped by the sidebar or screen edges. Auto-flips above the
* trigger when there's no room below.
*/
function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }) {
const { t } = useTranslation("workflows");
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [coords, setCoords] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const POPOVER_W = 340;
const MARGIN = 12;
useLayoutEffect(() => {
if (!open) return;
const update = () => {
const btn = buttonRef.current;
const pop = popoverRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
const popH = pop?.offsetHeight ?? 280;
// Center horizontally over the icon, clamp to viewport.
let left = r.left + r.width / 2 - POPOVER_W / 2;
if (left < MARGIN) left = MARGIN;
if (left + POPOVER_W > window.innerWidth - MARGIN) {
left = window.innerWidth - POPOVER_W - MARGIN;
}
// Default below the icon; flip above if not enough room.
const spaceBelow = window.innerHeight - r.bottom;
const placeAbove = spaceBelow < popH + MARGIN && r.top > popH + MARGIN;
const top = placeAbove ? Math.max(MARGIN, r.top - popH - 8) : r.bottom + 8;
setCoords({ left, top });
};
update();
const raf = requestAnimationFrame(update);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
return (
<>
<button
ref={buttonRef}
type="button"
aria-label={t("chartInfo.labels.what")}
aria-expanded={open}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
className="flex items-center justify-center rounded-full p-0.5 -m-0.5 text-gray-600 hover:text-gray-400 transition-colors focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<Info className="w-3.5 h-3.5" />
</button>
{open && (
<div
ref={popoverRef}
role="tooltip"
className="fixed z-50 p-3.5 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-gray-300 pointer-events-none"
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
>
<p className="text-xs font-semibold text-gray-100 mb-2.5 pb-2 border-b border-[#2a2a4a]">
{title}
</p>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1">
{t("chartInfo.labels.what")}
</p>
<p className="text-gray-400 leading-snug mb-2.5">{t(`chartInfo.${infoKey}.what`)}</p>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1">
{t("chartInfo.labels.howToRead")}
</p>
<p className="text-gray-400 leading-snug mb-2.5">{t(`chartInfo.${infoKey}.howToRead`)}</p>
<p className="font-semibold text-gray-200 uppercase tracking-wider text-[9px] mb-1">
{t("chartInfo.labels.why")}
</p>
<p className="text-gray-400 leading-snug">{t(`chartInfo.${infoKey}.why`)}</p>
</div>
)}
</>
);
}
// ── Page Header ──
function PageHeader({
statusFilter,
onStatusFilterChange,
onRefresh,
onExport,
lastUpdated,
}: {
statusFilter: StatusFilter;
onStatusFilterChange: (f: StatusFilter) => void;
onRefresh: () => void;
onExport: () => void;
lastUpdated: Date | null;
}) {
const { t } = useTranslation("workflows");
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const filters: { value: StatusFilter; label: string }[] = [
{ value: "all", label: t("allSessions") },
{ value: "active", label: t("activeOnly") },
{ value: "completed", label: t("completed") },
];
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<Workflow className="w-4.5 h-4.5 text-accent" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
{t("common:live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
{t("common:offline")}
</span>
)}
</div>
<p className="text-xs text-gray-500">{t("subtitle")}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Status filter tabs */}
<div className="flex bg-surface-2 rounded-lg p-0.5 border border-border">
{filters.map((f) => (
<button
key={f.value}
onClick={() => onStatusFilterChange(f.value)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
statusFilter === f.value
? "bg-accent/15 text-accent"
: "text-gray-500 hover:text-gray-300"
}`}
>
{f.label}
</button>
))}
</div>
{/* Actions */}
<button
onClick={onRefresh}
className="p-2 rounded-lg text-gray-500 hover:text-gray-300 hover:bg-surface-3 transition-colors"
title={t("refreshData")}
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={onExport}
className="p-2 rounded-lg text-gray-500 hover:text-gray-300 hover:bg-surface-3 transition-colors"
title={t("exportJson")}
>
<Download className="w-4 h-4" />
</button>
{lastUpdated && (
<span className="text-[10px] text-gray-600 ml-1">
{t("common:updated")}
{lastUpdated.toLocaleTimeString()}
</span>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
/**
* @file Run.defaultCwd.test.tsx
* @description The Run page must default its working directory to the user's
* HOME directory (a neutral spawn location) rather than the dashboard's own
* cwd, which would make ad-hoc runs inherit this repo's project context
* (.claude/agents, skills, rules, CLAUDE.md, .mcp.json) — issue #202. Falls
* back to the dashboard cwd only when no home suggestion is available.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import i18n from "i18next";
const HOME = { kind: "home", path: "/Users/tester", label: "Home" };
const DASHBOARD = {
kind: "dashboard",
path: "/Users/tester/projects/dashboard",
label: "Dashboard server",
};
const RECENT = { kind: "recent", path: "/Users/tester/projects/other", label: "other" };
// Mutable per-test cwd suggestion payload, read by the api mock below.
let cwdItems: Array<{ kind: string; path: string; label: string }> = [];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
...actual,
api: {
run: {
list: r({ runs: [], items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: vi.fn().mockImplementation(() => Promise.resolve({ items: cwdItems })),
files: r({ items: [] }),
start: r({ id: "run-1", status: "running" }),
get: r({ id: "run-1", status: "running", messages: [], envelopes: [] }),
send: r({ messageId: "m-1" }),
kill: r({ ok: true }),
},
ccConfig: {
commands: r({ items: [] }),
plugins: r({ plugins: [] }),
file: r({ content: "" }),
},
sessions: {
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),
transcript: r({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { Workspace as Run } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
async function renderRun() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Run />
</MemoryRouter>
);
await settle();
return utils;
}
function cwdInput(): HTMLInputElement {
const placeholder = i18n.t("run:fields.cwdPlaceholder");
return screen.getByPlaceholderText(placeholder) as HTMLInputElement;
}
beforeEach(() => {
i18n.changeLanguage("en");
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Run page — default working directory (issue #202)", () => {
it("pre-fills cwd with the HOME suggestion, not the dashboard cwd", async () => {
cwdItems = [DASHBOARD, HOME, RECENT];
await renderRun();
expect(cwdInput().value).toBe(HOME.path);
});
it("falls back to the dashboard cwd when no home suggestion exists", async () => {
cwdItems = [DASHBOARD, RECENT];
await renderRun();
expect(cwdInput().value).toBe(DASHBOARD.path);
});
it("leaves cwd empty when there are no suggestions at all", async () => {
cwdItems = [];
await renderRun();
expect(cwdInput().value).toBe("");
});
});
@@ -0,0 +1,390 @@
/**
* @file SessionDetail.nestedAgents.test.tsx
* @description Tests for SessionDetail page focusing on correct rendering of nested agent hierarchies, including edge cases like orphaned subagents and multiple main agents. Validates expand/collapse behavior and descendant counts in the UI.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, within, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { SessionDetail } from "../SessionDetail";
import type { Agent, Session, DashboardEvent } from "../../lib/types";
import { fmtCost } from "../../lib/format";
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
session_id: "sess-1",
name: "Main Agent",
type: "main",
subagent_type: null,
status: "working",
task: null,
current_tool: null,
started_at: "2026-03-05T10:00:00.000Z",
ended_at: null,
updated_at: "2026-03-05T10:00:00.000Z",
parent_agent_id: null,
metadata: null,
...overrides,
};
}
const mockSession: Session = {
id: "sess-1",
name: "Test Session",
status: "active",
cwd: "/test",
model: "claude-opus-4-6",
started_at: "2026-03-05T10:00:00.000Z",
ended_at: null,
metadata: null,
};
// ── Mock API ─────────────────────────────────────────────────────────────────
let mockAgents: Agent[] = [];
let mockCost: { total_cost: number; breakdown: unknown[] } = { total_cost: 0, breakdown: [] };
vi.mock("../../lib/api", () => ({
api: {
sessions: {
get: vi.fn(() =>
Promise.resolve({
session: mockSession,
agents: mockAgents,
events: [] as DashboardEvent[],
})
),
transcripts: vi.fn(() => Promise.resolve({ transcripts: [] })),
stats: vi.fn(() =>
Promise.resolve({
session_id: mockSession.id,
total_events: 0,
events_by_type: [],
tools_used: [],
error_count: 0,
first_event_at: null,
last_event_at: null,
agents: { total: 0, main: 0, subagent: 0, compaction: 0, by_status: {} },
subagent_types: [],
tokens: {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
},
})
),
},
pricing: {
sessionCost: vi.fn(() => Promise.resolve(mockCost)),
},
events: {
list: vi.fn(() =>
Promise.resolve({
events: [] as DashboardEvent[],
limit: 50,
offset: 0,
total: 0,
})
),
facets: vi.fn(() => Promise.resolve({ event_types: [], tool_names: [] })),
},
},
}));
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: vi.fn(() => () => {}),
},
}));
function renderPage() {
return render(
<MemoryRouter initialEntries={["/sessions/sess-1"]}>
<Routes>
<Route path="/sessions/:id" element={<SessionDetail />} />
</Routes>
</MemoryRouter>
);
}
/** Get the agent-tree region for scoped queries (avoids matches in the active-agent banner). */
async function findTree() {
return await screen.findByTestId("agent-tree");
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("SessionDetail - Nested Agent Tree Rendering", () => {
beforeEach(() => {
mockAgents = [];
mockCost = { total_cost: 0, breakdown: [] };
});
it("shows the session total cost on the main agent card (cost is loaded separately)", async () => {
// Regression: /api/sessions/:id has no cost column, so the main card (which
// renders session.cost) showed nothing until SessionDetail injected the
// separately-loaded total into the card's session prop.
mockCost = { total_cost: 42.5, breakdown: [] };
mockAgents = [makeAgent({ id: "main-1", name: "Main Agent", type: "main", status: "waiting" })];
renderPage();
const tree = await findTree();
await waitFor(() => expect(within(tree).getByText(fmtCost(42.5))).toBeInTheDocument());
});
it("renders a flat main → subagent hierarchy (depth 1)", async () => {
mockAgents = [
makeAgent({ id: "main-1", name: "Main Agent", type: "main", status: "working" }),
makeAgent({
id: "sub-1",
name: "Explorer",
type: "subagent",
subagent_type: "Explore",
status: "working",
parent_agent_id: "main-1",
}),
];
renderPage();
expect(await screen.findByText("Main Agent")).toBeInTheDocument();
// Subagent should be visible (auto-expanded because it's working)
expect(await screen.findByText("Explorer")).toBeInTheDocument();
});
it("renders deeply nested agents (depth 3: main → L1 → L2 → L3)", async () => {
mockAgents = [
makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }),
makeAgent({
id: "l1",
name: "Level-1",
type: "subagent",
status: "working",
parent_agent_id: "main-1",
}),
makeAgent({
id: "l2",
name: "Level-2",
type: "subagent",
status: "working",
parent_agent_id: "l1",
}),
makeAgent({
id: "l3",
name: "Level-3",
type: "subagent",
status: "working",
parent_agent_id: "l2",
}),
];
renderPage();
const tree = await findTree();
// All levels should render in the tree (auto-expanded because they have working children).
// The active-agent banner may also display "Level-1", so we scope to the tree.
await waitFor(() => expect(within(tree).getByText("Main")).toBeInTheDocument());
expect(within(tree).getByText("Level-1")).toBeInTheDocument();
expect(within(tree).getByText("Level-2")).toBeInTheDocument();
expect(within(tree).getByText("Level-3")).toBeInTheDocument();
});
it("shows descendant count in collapsed badge for nested agents", async () => {
mockAgents = [
makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }),
makeAgent({
id: "l1",
name: "Level-1",
type: "subagent",
status: "completed",
parent_agent_id: "main-1",
}),
makeAgent({
id: "l2",
name: "Level-2",
type: "subagent",
status: "completed",
parent_agent_id: "l1",
}),
makeAgent({
id: "l3a",
name: "Level-3a",
type: "subagent",
status: "completed",
parent_agent_id: "l2",
}),
makeAgent({
id: "l3b",
name: "Level-3b",
type: "subagent",
status: "completed",
parent_agent_id: "l2",
}),
];
renderPage();
// Main has 4 total descendants (L1, L2, L3a, L3b) - should show "4 subagents" when collapsed
expect(await screen.findByText("4 subagents")).toBeInTheDocument();
});
it("expands and collapses nested agent groups", async () => {
mockAgents = [
makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }),
makeAgent({
id: "l1",
name: "Level-1",
type: "subagent",
status: "completed",
parent_agent_id: "main-1",
}),
makeAgent({
id: "l2",
name: "Level-2",
type: "subagent",
status: "completed",
parent_agent_id: "l1",
}),
];
renderPage();
// Initially collapsed (agents are completed, no auto-expand)
expect(await screen.findByText("2 subagents")).toBeInTheDocument();
expect(screen.queryByText("Level-1")).not.toBeInTheDocument();
// Click the count badge to expand
fireEvent.click(screen.getByText("2 subagents"));
expect(await screen.findByText("Level-1")).toBeInTheDocument();
// Level-2 is still nested under Level-1 which is collapsed
expect(screen.getByText("1 subagent")).toBeInTheDocument();
});
it("renders orphaned subagents in dedicated section", async () => {
mockAgents = [
makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }),
makeAgent({
id: "orphan-1",
name: "Orphan Agent",
type: "subagent",
status: "working",
parent_agent_id: "nonexistent-parent",
}),
];
renderPage();
const tree = await findTree();
expect(within(tree).getByText("Main")).toBeInTheDocument();
expect(within(tree).getByText("Orphan Agent")).toBeInTheDocument();
expect(await screen.findByText("Unparented Subagents")).toBeInTheDocument();
});
it("auto-expands all ancestors when a deeply nested agent is active", async () => {
// Level-3 is working → Level-2, Level-1, and Main should all auto-expand
mockAgents = [
makeAgent({ id: "main-1", name: "Main", type: "main", status: "waiting" }),
makeAgent({
id: "l1",
name: "Level-1",
type: "subagent",
status: "working",
parent_agent_id: "main-1",
}),
makeAgent({
id: "l2",
name: "Level-2",
type: "subagent",
status: "working",
parent_agent_id: "l1",
}),
makeAgent({
id: "l3",
name: "Deep Active",
type: "subagent",
status: "working",
parent_agent_id: "l2",
}),
];
renderPage();
const tree = await findTree();
// All levels should be visible because l3 is working, triggering ancestor expansion
await waitFor(() => expect(within(tree).getByText("Main")).toBeInTheDocument());
expect(within(tree).getByText("Level-1")).toBeInTheDocument();
expect(within(tree).getByText("Level-2")).toBeInTheDocument();
expect(within(tree).getByText("Deep Active")).toBeInTheDocument();
});
it("handles agents with no children (leaf nodes)", async () => {
mockAgents = [makeAgent({ id: "main-1", name: "Main", type: "main", status: "working" })];
renderPage();
expect(await screen.findByText("Main")).toBeInTheDocument();
// No subagent-count button should exist for a leaf node. Match the
// "{{count}} subagent(s)" label specifically so we don't collide with
// the word "subagent" in unrelated explanatory copy elsewhere on the page.
expect(screen.queryByText(/\d+ subagent/)).not.toBeInTheDocument();
});
it("renders multiple main agents in the same session", async () => {
// Edge case: import/resume could create multiple "main" entries
mockAgents = [
makeAgent({ id: "main-1", name: "Main-A", type: "main", status: "completed" }),
makeAgent({ id: "main-2", name: "Main-B", type: "main", status: "working" }),
makeAgent({
id: "sub-a",
name: "Sub of A",
type: "subagent",
status: "completed",
parent_agent_id: "main-1",
}),
makeAgent({
id: "sub-b",
name: "Sub of B",
type: "subagent",
status: "working",
parent_agent_id: "main-2",
}),
];
renderPage();
const tree = await findTree();
await waitFor(() => expect(within(tree).getByText("Main-A")).toBeInTheDocument());
expect(within(tree).getByText("Main-B")).toBeInTheDocument();
// Sub of B auto-expanded (working)
expect(within(tree).getByText("Sub of B")).toBeInTheDocument();
});
it("renders sibling subagents at the same depth", async () => {
mockAgents = [
makeAgent({ id: "main-1", name: "Main", type: "main", status: "working" }),
makeAgent({
id: "sub-a",
name: "Sibling-A",
type: "subagent",
status: "working",
parent_agent_id: "main-1",
}),
makeAgent({
id: "sub-b",
name: "Sibling-B",
type: "subagent",
status: "working",
parent_agent_id: "main-1",
}),
makeAgent({
id: "sub-c",
name: "Sibling-C",
type: "subagent",
status: "completed",
parent_agent_id: "main-1",
}),
];
renderPage();
expect(await screen.findByText("Main")).toBeInTheDocument();
expect(await screen.findByText("Sibling-A")).toBeInTheDocument();
expect(await screen.findByText("Sibling-B")).toBeInTheDocument();
expect(await screen.findByText("Sibling-C")).toBeInTheDocument();
});
});
@@ -0,0 +1,528 @@
/**
* @file Workspace.test.tsx
* @description Workspace page integration tests covering the lane-based run flow.
* Verifies: lanes API contract, ensure before start flow, lane start vs /api/run,
* and that no /stage endpoint is called.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
// Track all API calls made by recording the method and endpoint
const recordedCalls: Array<{ method: string; path: string }> = [];
// Mutable test state. `run_id` is widened explicitly: the fixtures start with
// no run, and one case assigns run ids to drive the console.
type LaneFixture = {
id: number;
cwd: string;
title: string;
pipeline_name: string;
pipeline_nodes: never[];
detected_signal: string | null;
run_id: string | null;
};
let lanesToReturn: LaneFixture[] = [
{
id: 1,
cwd: "/workspace/a",
title: "Lane A",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 2,
cwd: "/workspace/b",
title: "Lane B",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
];
let countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
let nextRunId = 1;
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const recordCall = (method: string, path: string) => {
recordedCalls.push({ method, path });
};
return {
...actual,
api: {
lanes: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
return { lanes: lanesToReturn, counts: countsToReturn };
}),
ensure: vi.fn().mockImplementation(async ({ cwd }: { cwd: string }) => {
recordCall("POST", "/api/lanes/ensure");
const newLane = {
id: Math.max(...lanesToReturn.map((l) => l.id), 0) + 1,
cwd,
title: `Lane for ${cwd}`,
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
};
lanesToReturn = [...lanesToReturn, newLane];
return { lane: newLane, created: true };
}),
action: vi.fn().mockImplementation(async (id: number, action: string) => {
recordCall("POST", `/api/lanes/${id}/${action}`);
if (action === "start") {
const runId = `run-${nextRunId++}`;
const lane = lanesToReturn.find((l) => l.id === id);
if (lane) {
lane.run_id = runId;
}
return { lane: { ...lane, run_id: runId } };
}
return { ok: true };
}),
// LaneCard reads its own working-copy facts per card. Nothing here
// asserts on them, so report the "not a git repo" shape.
git: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/git`);
return { available: false };
}),
stage: vi.fn().mockImplementation(async () => {
recordCall("POST", `/api/lanes/stage`);
return { ok: true };
}),
},
run: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/list");
return { runs: [], items: [] };
}),
history: vi.fn().mockImplementation(async (limit?: number, opts?: { laneId?: number }) => {
recordCall(
"GET",
`/api/run/history?limit=${limit}${opts?.laneId ? `&laneId=${opts.laneId}` : ""}`
);
return { items: [] };
}),
binary: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/binary");
return { found: true, path: "/usr/bin/claude" };
}),
cwds: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/cwds");
return { items: [{ kind: "home", path: "/home/user", label: "Home" }] };
}),
files: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/files");
return { items: [] };
}),
start: vi.fn().mockImplementation(async () => {
recordCall("POST", "/api/run/start");
const runId = `run-${nextRunId++}`;
return { id: runId, status: "running" };
}),
get: vi.fn().mockImplementation(async (id: string) => {
recordCall("GET", `/api/run/${id}`);
return {
id,
pid: 12345,
mode: "conversation",
cwd: "/workspace",
model: "claude-opus-5",
permissionMode: "acceptEdits",
effort: "medium",
prompt: "test prompt",
argv: [],
resumeSessionId: null,
status: "running",
startedAt: Date.now(),
endedAt: null,
exitCode: null,
signal: null,
error: null,
sessionId: "sess-1",
envelopeCount: 0,
stdoutTail: "",
stderrTail: "",
messages: [],
envelopes: [],
};
}),
send: vi.fn().mockImplementation(async (id: string) => {
recordCall("POST", `/api/run/${id}/send`);
return { messageId: "msg-1" };
}),
kill: vi.fn().mockImplementation(async (id: string) => {
recordCall("POST", `/api/run/${id}/kill`);
return { ok: true };
}),
},
ccConfig: {
commands: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/cc-config/commands");
return { items: [] };
}),
plugins: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/cc-config/plugins");
return { plugins: [] };
}),
file: vi.fn().mockImplementation(async (path: string) => {
recordCall("GET", `/api/cc-config/file?path=${path}`);
return { text: "", content: "" };
}),
},
sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
transcript: vi.fn().mockResolvedValue({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { Workspace } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
// Set up DOM polyfills for tests
if (typeof globalThis !== "undefined") {
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
if (typeof Element !== "undefined") {
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
}
}
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await settle();
return utils;
}
beforeEach(() => {
recordedCalls.length = 0;
lanesToReturn = [
{
id: 1,
cwd: "/workspace/a",
title: "Lane A",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 2,
cwd: "/workspace/b",
title: "Lane B",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
];
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
nextRunId = 1;
vi.clearAllMocks();
});
afterEach(() => {
recordedCalls.length = 0;
vi.clearAllMocks();
});
describe("Workspace page — lane integration", () => {
it("lane strip lists lanes and counters match the API response", async () => {
await renderWorkspace();
// Verify lanes are rendered
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
// Verify counters are rendered with correct values
const counterTexts = screen.getAllByText((_, element) => {
if (!element) return false;
return element.textContent?.includes("2 lanes") || false;
});
expect(counterTexts.length).toBeGreaterThan(0);
});
it("selecting a lane switches the pipeline and run id", async () => {
lanesToReturn = lanesToReturn.map((l, i) => ({
...l,
run_id: i === 0 ? "run-100" : "run-200",
}));
await renderWorkspace();
const user = userEvent.setup();
// Selection is announced on the tile itself, not inferred from styling.
const laneATile = screen.getByTestId("lane-tile-1");
const laneBTile = screen.getByTestId("lane-tile-2");
expect(laneATile.getAttribute("aria-pressed")).toBe("true");
expect(laneBTile.getAttribute("aria-pressed")).toBe("false");
await user.click(laneBTile);
await settle();
expect(laneBTile.getAttribute("aria-pressed")).toBe("true");
expect(laneATile.getAttribute("aria-pressed")).toBe("false");
// And the detail panel follows: only the selected lane gets a full card.
expect(screen.getByTestId("lane-card-2")).toBeInTheDocument();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
});
it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to an existing lane's cwd
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
const startButton = runButton;
await user.click(startButton);
await waitFor(() => {
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// Verify /api/run/start was NOT called
const runStartCalled = recordedCalls.some((c) => c.path === "/api/run/start");
expect(runStartCalled).toBe(false);
});
it("picking a cwd that no lane owns requests /api/lanes/ensure BEFORE start", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to a path no lane owns
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/new/project/path");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
await user.click(runButton);
await waitFor(() => {
expect(recordedCalls.some((c) => c.path === "/api/lanes/ensure")).toBe(true);
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// Verify ensure was called BEFORE start
const ensureIndex = recordedCalls.findIndex((c) => c.path === "/api/lanes/ensure");
const startIndex = recordedCalls.findIndex(
(c) => c.path.includes("/api/lanes/") && c.path.includes("/start")
);
expect(ensureIndex).toBeLessThan(startIndex);
});
it("after a full start-then-message cycle, no recorded request URL matches /stage", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to an existing lane's cwd
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
await user.click(runButton);
await waitFor(() => {
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// After start, verify no /stage call was made
const stageCalls = recordedCalls.filter((c) => c.path.includes("/stage"));
expect(stageCalls).toHaveLength(0);
});
});
describe("Workspace layout", () => {
it("shows the four counters with the values the API reported", async () => {
countsToReturn = { total: 2, running: 1, needs_you: 3, dead: 4 };
await renderWorkspace();
expect(screen.getByTestId("count-total").textContent).toContain("2");
expect(screen.getByTestId("count-running").textContent).toContain("1");
expect(screen.getByTestId("count-needs-you").textContent).toContain("3");
expect(screen.getByTestId("count-dead").textContent).toContain("4");
});
it("hides the needs-you and dead counters when they are zero", async () => {
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
await renderWorkspace();
expect(screen.queryByTestId("count-needs-you")).toBeNull();
expect(screen.queryByTestId("count-dead")).toBeNull();
});
it("gives every lane a tile in the carousel", async () => {
await renderWorkspace();
expect(screen.getByTestId("lane-strip")).toBeInTheDocument();
expect(screen.getByTestId("lane-tile-1")).toBeInTheDocument();
expect(screen.getByTestId("lane-tile-2")).toBeInTheDocument();
});
it("shows the full card only for the selected lane, in the detail panel", async () => {
await renderWorkspace();
// Lane 1 is selected by default. Its full card — controls, git facts — is
// the detail panel; the other lane stays a tile.
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
expect(screen.queryByTestId("lane-card-2")).toBeNull();
});
it("renders the selected lane's pipeline detail panel", async () => {
await renderWorkspace();
expect(screen.getByTestId("lane-detail")).toBeInTheDocument();
// The colour legend was removed: read once, noise thereafter.
expect(screen.queryByTestId("pipeline-legend")).toBeNull();
});
it("renders the console body without any collapse toggle", async () => {
await renderWorkspace();
// The disclosure was removed - the console is always attached and visible.
expect(screen.queryByTestId("console-toggle")).toBeNull();
const body = screen.getByTestId("console-body");
expect(body).toBeInTheDocument();
expect(body.className).not.toContain("hidden");
});
});
describe("Workspace — the console is its own section", () => {
it("does not nest the console inside any lane card", async () => {
await renderWorkspace();
const body = screen.getByTestId("console-body");
// The console is a window onto a process, not a property of a card. Nesting
// it in one made the owning card span the row and left a hole when closed.
expect(screen.getByTestId("lane-card-1").contains(body)).toBe(false);
expect(screen.getByTestId("lane-strip").contains(body)).toBe(false);
});
it("has no header line of its own - the pipeline panel already names the lane", async () => {
lanesToReturn = lanesToReturn.map((l, i) => ({ ...l, run_id: i === 0 ? "run-100" : null }));
await renderWorkspace();
expect(screen.queryByTestId("console-owner")).toBeNull();
expect(screen.queryByText("Claude console")).toBeNull();
});
it("keeps the console reachable when no lane exists at all", async () => {
lanesToReturn = [];
countsToReturn = { total: 0, running: 0, needs_you: 0, dead: 0 };
await renderWorkspace();
expect(screen.getByTestId("console-body")).toBeInTheDocument();
});
it("attaches under the selected lane's pipeline, not as a separate section", async () => {
await renderWorkspace();
const detail = screen.getByTestId("lane-detail");
const body = screen.getByTestId("console-body");
expect(detail.contains(body)).toBe(true);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,548 @@
/**
* @file screens.snapshot.test.tsx
* @description Render snapshot tests for every routed screen. Each page is
* rendered inside a MemoryRouter with the API layer mocked to a deterministic
* loaded-empty state (empty collections + zeroed scalars) so snapshots capture
* real structure / layout / i18n without noisy chart DOM or live data. The
* system clock and timezone are pinned so any relative/absolute timestamps are
* stable across machines and CI.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
// Pin timezone before anything reads it, so date formatting is machine-stable
// (referenced via globalThis so the browser-targeted tsconfig doesn't need node types).
const nodeProcess = (
globalThis as unknown as { process?: { env: Record<string, string | undefined> } }
).process;
if (nodeProcess) nodeProcess.env.TZ = "UTC";
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
import type { ReactNode } from "react";
import { render, act } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import i18n from "i18next";
// ── Mock the API layer with deterministic, crash-safe empty fixtures ──────────
// Keep the module's other named exports (RUN_EFFORT_CHOICES, etc.) real and
// override only `api`.
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
const items = { items: [] };
const cost = {
total_cost: 0,
breakdown: [],
daily_costs: [],
feature_costs: {},
unpriced_models: [],
};
const emptyWorkflow = {
stats: {
totalSessions: 0,
totalAgents: 0,
totalSubagents: 0,
avgSubagents: 0,
successRate: 0,
avgDepth: 0,
avgDurationSec: 0,
totalCompactions: 0,
avgCompactions: 0,
topFlow: null,
},
orchestration: {
sessionCount: 0,
mainCount: 0,
subagentTypes: [],
edges: [],
outcomes: [],
compactions: { total: 0, sessions: 0 },
},
toolFlow: { transitions: [], toolCounts: [] },
effectiveness: [],
patterns: { patterns: [], soloSessionCount: 0, soloPercentage: 0 },
modelDelegation: { mainModels: [], subagentModels: [], tokensByModel: [] },
errorPropagation: {
byDepth: [],
byType: [],
eventErrors: [],
sessionsWithErrors: 0,
totalSessions: 0,
errorRate: 0,
},
concurrency: { aggregateLanes: [] },
complexity: [],
compaction: {
totalCompactions: 0,
tokensRecovered: 0,
perSession: [],
sessionsWithCompactions: 0,
totalSessions: 0,
},
cooccurrence: [],
};
const analytics = {
tokens: { total_input: 0, total_output: 0, total_cache_read: 0, total_cache_write: 0 },
tool_usage: [],
daily_events: [],
daily_sessions: [],
agent_types: [],
event_types: [],
avg_events_per_session: 0,
total_subagents: 0,
overview: {
total_sessions: 0,
active_sessions: 0,
active_agents: 0,
total_agents: 0,
total_events: 0,
},
agents_by_status: {},
sessions_by_status: {},
};
const settingsInfo = {
db: {
path: "/tmp/test.db",
size: 0,
counts: {},
pragmas: {
journal_mode: "wal",
synchronous: 1,
auto_vacuum: 0,
encoding: "UTF-8",
foreign_keys: 1,
busy_timeout: 5000,
},
load_stats: { m5: 0, m15: 0, h1: 0 },
},
hooks: { installed: true, path: "/tmp/settings.json", hooks: {} },
server: {
uptime: 0,
node_version: "v22.0.0",
platform: "linux",
ws_connections: 0,
memory: { rss: 0, heapTotal: 0, heapUsed: 0, external: 0 },
cpu_load: [0, 0, 0],
arch: "x64",
total_mem: 0,
free_mem: 0,
cpus: 1,
},
transcript_cache: { size: 0, maxSize: 100, hits: 0, misses: 0, keys: [] },
};
const session = {
id: "sess-1",
name: "Test Session",
status: "active",
cwd: "/test",
model: "claude-opus-4-6",
started_at: "2026-06-10T12:00:00.000Z",
ended_at: null,
metadata: null,
};
const stats = {
total_sessions: 0,
active_sessions: 0,
active_agents: 0,
total_agents: 0,
total_events: 0,
events_today: 0,
ws_connections: 0,
agents_by_status: {},
sessions_by_status: {},
};
const sampleWorkflowRun = {
run_id: "wf_sample1",
session_id: "sess-1",
task_id: "task-1",
name: "review-changes",
status: "completed",
default_model: "claude-opus-4-6",
started_at: "2026-06-10T12:30:00.000Z",
ended_at: "2026-06-10T12:35:00.000Z",
duration_ms: 300000,
agent_count: 2,
total_tokens: 48000,
total_tool_calls: 9,
phases: [{ title: "Review" }, { title: "Verify" }],
progress: [
{ type: "workflow_phase", index: 1, title: "Review" },
{ type: "workflow_phase", index: 2, title: "Verify" },
{
type: "workflow_agent",
agentId: "a1",
agentType: "reviewer",
state: "done",
label: "review:bugs",
phaseTitle: "Review",
tokens: 22000,
toolCalls: 5,
durationMs: 120000,
lastToolName: "Read",
resultPreview: "Found 3 issues",
},
{
type: "workflow_agent",
agentId: "a2",
agentType: "verifier",
state: "done",
label: "verify:bugs",
phaseTitle: "Verify",
tokens: 26000,
toolCalls: 4,
durationMs: 180000,
lastToolName: "Bash",
resultPreview: "All confirmed",
},
],
script_path: null,
journal_path: "/x/wf_sample1.json",
source: "journal",
created_at: "2026-06-10T12:30:00.000Z",
updated_at: "2026-06-10T12:35:00.000Z",
};
return {
...actual,
api: {
stats: { get: r(stats), facets: r({ cwds: [] }) },
sessions: {
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),
facets: r({ cwds: [], sources: ["local"] }),
get: r({ session, agents: [], events: [], workflows: [sampleWorkflowRun] }),
stats: r({
session_id: "sess-1",
total_events: 0,
events_by_type: [],
tools_used: [],
error_count: 0,
first_event_at: null,
last_event_at: null,
agents: { total: 0, main: 0, subagent: 0, compaction: 0, by_status: {} },
subagent_types: [],
tokens: {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
},
}),
transcripts: r({ transcripts: [] }),
transcript: r({ messages: [], session_id: "sess-1" }),
},
agents: { list: r({ agents: [] }) },
remoteSources: {
list: r({ sources: [] }),
create: r({ source: {} }),
update: r({ source: {} }),
remove: r({ ok: true, purged: 0 }),
test: r({ ok: true, message: "" }),
sync: r({ ok: true }),
},
events: {
list: r({ events: [], total: 0, limit: 50, offset: 0 }),
facets: r({ event_types: [], tool_names: [] }),
},
analytics: { get: r(analytics) },
workflows: {
get: r(emptyWorkflow),
session: r({}),
runs: r({
runs: [sampleWorkflowRun],
total: 1,
counts: { completed: 1 },
limit: 200,
offset: 0,
}),
run: r({ workflow: sampleWorkflowRun, agents: [], events: [] }),
},
pricing: {
list: r({ pricing: [] }),
upsert: r({ pricing: {} }),
delete: r({ ok: true }),
totalCost: r(cost),
sessionCost: r(cost),
},
settings: {
info: r(settingsInfo),
claudeHome: {
get: r({ claude_home: "/home/test/.claude" }),
set: r({ ok: true, claude_home: "/home/test/.claude" }),
},
clearData: r({ ok: true, cleared: {} }),
reimport: r({ ok: true, imported: 0, skipped: 0, errors: 0 }),
reinstallHooks: r({ ok: true, hooks: { installed: true, hooks: {} } }),
resetPricing: r({ ok: true, pricing: [] }),
exportData: () => "/api/settings/export",
cleanup: r({
ok: true,
abandoned: 0,
purged_sessions: 0,
purged_events: 0,
purged_agents: 0,
}),
},
import: {
guide: r({
platform: "linux",
default_projects_dir: "/home/test/.claude/projects",
default_projects_dir_display: "~/.claude/projects",
default_projects_dir_exists: true,
default_projects_dir_stats: { projects: 0, jsonl_files: 0 },
archive_command: "tar",
supported_extensions: [".jsonl"],
max_upload_bytes: 1000000,
max_upload_files: 10,
steps: [],
}),
rescan: r({}),
scanPath: r({}),
},
ccConfig: {
overview: r({
roots: {
claudeHome: "/home/test/.claude",
projectClaudeDir: "/test/.claude",
projectRoot: "/test",
claudeJson: "/home/test/.claude.json",
},
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
commands: { user: 0, project: 0 },
outputStyles: { user: 0, project: 0 },
plugins: 0,
pluginsEnabled: 0,
pluginsDisabled: 0,
marketplaces: 0,
keybindings: 0,
mcpServers: { user: 0, project: 0 },
hooks: {},
memory: 0,
settingsFiles: 0,
},
}),
skills: r(items),
agents: r(items),
commands: r(items),
outputStyles: r(items),
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
mcp: r({ servers: [], items: [] }),
hooks: r(items),
settings: r(items),
memory: r(items),
file: r({
scope: "user",
name: "x",
path: "/x",
size: 0,
mtime: 0,
truncated: false,
frontmatter: {},
preview: "",
content: "",
}),
write: r({ ok: true }),
delete: r({ ok: true }),
marketplaces: r({ marketplaces: [], items: [] }),
keybindings: r({ items: [], bindings: [] }),
statusline: r({ configured: false }),
hookScripts: r({ items: [], scripts: [] }),
backups: r({ items: [] }),
},
run: {
list: r({ runs: [], items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: r({ items: [] }),
files: r({ items: [] }),
start: r({ id: "run-1", status: "running" }),
get: r({ id: "run-1", status: "running", messages: [], envelopes: [] }),
send: r({ messageId: "m-1" }),
kill: r({ ok: true }),
},
alerts: {
list: r({ alerts: [], total: 0, unacked: 0, limit: 50, offset: 0 }),
ack: r({ alert: {} }),
ackAll: r({ ok: true, acknowledged: 0 }),
rules: {
list: r({ rules: [] }),
create: r({ rule: {} }),
update: r({ rule: {} }),
remove: r({ ok: true }),
},
},
webhooks: {
list: r({ targets: [] }),
providers: r({ providers: [] }),
create: r({ target: {} }),
update: r({ target: {} }),
remove: r({ ok: true }),
test: r({ ok: true, status: 200, attempts: 1, error: null }),
deliveries: r({ deliveries: [], limit: 20, offset: 0 }),
},
lanes: {
list: r({ lanes: [], counts: { total: 0, running: 0, needs_you: 0, dead: 0 } }),
get: r({ lane: {} }),
create: r({ lane: {} }),
update: r({ lane: {} }),
stage: r({ lane: {} }),
// A realistic shape: `{}` would crash blockingReason on `blocked` if any
// screen ever opened the destructive modal.
preflight: r({
action: "reset",
lane: 1,
kind: "managed",
branch: "feat/demo",
head: "abc1234",
dirty: 0,
untracked: 0,
unpushed: 0,
blocked: [],
warnings: [],
}),
action: r({ ok: true }),
},
updates: { check: r({ behind: 0, ahead: 0, current: "", upstream: "" }), status: r({}) },
},
};
});
// eventBus: no-op pub/sub so pages mount without a live socket. onConnection +
// connected back the useSyncExternalStore(eventBus.onConnection, …) reads.
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
// push notifications: avoid real service-worker / Notification calls.
vi.mock("../../lib/push", () => ({
subscribeToPush: vi.fn().mockResolvedValue(undefined),
unsubscribeFromPush: vi.fn().mockResolvedValue(undefined),
}));
// Page components (imported after the mocks above; vi.mock is hoisted).
import { Dashboard } from "../Dashboard";
import { KanbanBoard } from "../KanbanBoard";
import { Sessions } from "../Sessions";
import { SessionDetail } from "../SessionDetail";
import { ActivityFeed } from "../ActivityFeed";
import { Analytics } from "../Analytics";
import { Workflows } from "../Workflows";
import { CcConfig } from "../CcConfig";
import { Workspace } from "../Workspace";
import { Settings } from "../Settings";
import { NotFound } from "../NotFound";
// jsdom lacks these browser APIs that chart / responsive components rely on.
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
globalThis.IntersectionObserver =
globalThis.IntersectionObserver || (ObserverStub as unknown as typeof IntersectionObserver);
if (!window.matchMedia) {
window.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return false;
},
}) as unknown as MediaQueryList;
}
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
// Flush pending promises (mocked API resolves) + effects so the loaded state
// is rendered before snapshotting.
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
async function snapshot(ui: ReactNode, route = "/") {
const { container } = render(<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>);
await settle();
expect(container).toMatchSnapshot();
}
beforeAll(() => {
// Fake only Date so relative/absolute times are deterministic; leave timers
// real so setTimeout-based flushing in settle() still works.
vi.useFakeTimers({ now: new Date("2026-06-10T13:00:00.000Z"), toFake: ["Date"] });
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => {
i18n.changeLanguage("en");
});
afterEach(() => {
vi.clearAllMocks();
});
describe("screen snapshots", () => {
it("Dashboard", async () => {
await snapshot(<Dashboard />, "/");
});
it("Kanban board", async () => {
await snapshot(<KanbanBoard />, "/kanban");
});
it("Sessions", async () => {
await snapshot(<Sessions />, "/sessions");
});
it("Session detail", async () => {
await snapshot(
<Routes>
<Route path="/sessions/:id" element={<SessionDetail />} />
</Routes>,
"/sessions/sess-1"
);
});
it("Activity feed", async () => {
await snapshot(<ActivityFeed />, "/activity");
});
it("Analytics", async () => {
await snapshot(<Analytics />, "/analytics");
});
it("Workflows", async () => {
await snapshot(<Workflows />, "/workflows");
});
it("Claude Config", async () => {
await snapshot(<CcConfig />, "/cc-config");
});
it("Run", async () => {
await snapshot(<Workspace />, "/run");
});
it("Settings", async () => {
await snapshot(<Settings />, "/settings");
});
it("Not found", async () => {
await snapshot(<NotFound />, "/nope");
});
});