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:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user