/** * @file SessionDrillIn.tsx * @description Defines the SessionDrillIn component, which provides a detailed view of a specific session in the agent dashboard application. It allows users to drill into the agent tree, tool timeline, and event sequence for a selected session. The component manages its own state for loading, error handling, and active tab selection, and it fetches the necessary data from the backend API when a session is selected. It also includes a session selector for searching and selecting different sessions to view. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * 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. * * ## Internal dependencies * - `../../lib/api` * - `../../lib/format` * - `../../lib/types` * * ## Public surface * - `SessionDrillInProps` — exported API; see TSDoc on the symbol for behavior. * - `SessionDrillIn` — 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). * ----------------------------------------------------------------------------- * **SessionDrillInProps** * 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. * * **SessionDrillIn** * 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 { useState, useEffect, useRef, useCallback } from "react"; import { X, GitFork, Wrench, List, Search, ChevronDown } from "lucide-react"; import { useTranslation } from "react-i18next"; import { api } from "../../lib/api"; import { formatDateTime, formatMs, formatModelName } from "../../lib/format"; import type { SessionDrillIn as SessionDrillInData, DashboardEvent, Session, } from "../../lib/types"; // ── Types ───────────────────────────────────────────────────────────────────── type Tab = "tree" | "timeline" | "events"; type AgentNode = SessionDrillInData["tree"][number]; // ── Helpers ─────────────────────────────────────────────────────────────────── function statusColor(status: string): string { switch (status) { case "completed": return "text-violet-400 bg-violet-500/10 border-violet-500/20"; case "working": return "text-status-success bg-status-success/10 border-status-success/20"; case "error": return "text-status-danger bg-status-danger/10 border-status-danger/20"; case "active": return "text-status-success bg-status-success/10 border-status-success/20"; case "waiting": return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20"; default: return "text-fg-secondary bg-surface-4/10 border-border-light/20"; } } function safeTimestamp(raw: string): string { try { const normalized = /[Zz]$|[+-]\d{2}:\d{2}$/.test(raw) ? raw : raw.replace(" ", "T") + "Z"; return formatDateTime(normalized); } catch { return raw; } } // ── Tab bar ─────────────────────────────────────────────────────────────────── interface TabBarProps { active: Tab; onChange: (t: Tab) => void; } function TabBar({ active, onChange }: TabBarProps) { const { t } = useTranslation("workflows"); const tabs = [ { id: "tree" as Tab, label: t("drillIn.tabs.agentTree"), icon: , }, { id: "timeline" as Tab, label: t("drillIn.tabs.toolTimeline"), icon: , }, { id: "events" as Tab, label: t("drillIn.tabs.eventSequence"), icon: , }, ]; return (
{tabs.map((tab) => ( ))}
); } // ── Agent Tree ──────────────────────────────────────────────────────────────── interface TreeNodeProps { node: AgentNode; depth: number; } function TreeNode({ node, depth }: TreeNodeProps) { const { t } = useTranslation(["workflows", "common"]); const indentPx = depth * 20; const isMain = node.type === "main"; const dur = node.ended_at ? formatMs( Math.max( 0, new Date(node.ended_at + "Z").getTime() - new Date(node.started_at + "Z").getTime() ) ) : t("common:running"); const sc = statusColor(node.status); const statusLabel = t(`common:status.${node.status}`, { defaultValue: node.status }); return (
{/* Depth connector line */} {depth > 0 && } {/* Status badge */} {statusLabel} {/* Name */} {node.name} {/* Subagent type */} {node.subagent_type && ( [{node.subagent_type}] )} {/* Duration */} {dur}
{node.children.length > 0 && (
{node.children.map((child) => ( ))}
)}
); } interface AgentTreeProps { tree: SessionDrillInData["tree"]; } function AgentTree({ tree }: AgentTreeProps) { const { t } = useTranslation("workflows"); if (tree.length === 0) { return

{t("drillIn.noAgentTree")}

; } return (
{tree.map((node) => ( ))}
); } // ── Tool Timeline ───────────────────────────────────────────────────────────── type ToolEvent = SessionDrillInData["toolTimeline"][number]; interface ToolTimelineProps { events: ToolEvent[]; } function ToolTimeline({ events }: ToolTimelineProps) { const { t } = useTranslation("workflows"); if (events.length === 0) { return

{t("drillIn.noToolEvents")}

; } return (
{events.map((ev) => (
{/* Tool pill */} {ev.tool_name ?? ev.event_type} {/* Summary */} {ev.summary && ( {ev.summary} )} {/* Timestamp */} {safeTimestamp(ev.created_at)}
))}
); } // ── Event Sequence ──────────────────────────────────────────────────────────── interface EventSequenceProps { events: DashboardEvent[]; } const EVENT_TYPE_COLOR: Record = { tool_use: "text-blue-500", tool_result: "text-status-success", agent_start: "text-indigo-400", agent_stop: "text-violet-400", compaction: "text-status-warning", error: "text-status-danger", }; function eventTypeColor(type: string): string { return EVENT_TYPE_COLOR[type] ?? "text-fg-secondary"; } function EventSequence({ events }: EventSequenceProps) { const { t } = useTranslation("workflows"); if (events.length === 0) { return

{t("drillIn.noEvents")}

; } const recent = events.slice(0, 100); return (
{recent.map((ev) => (
{/* Event type badge */} {ev.event_type} {/* Summary */} {ev.summary ?? ev.tool_name ?? "-"} {/* Timestamp */} {safeTimestamp(ev.created_at)}
))} {events.length > 100 && (

{t("drillIn.showingOf", { total: events.length })}

)}
); } // ── Loading / Error states ──────────────────────────────────────────────────── function LoadingState() { return (
{[...Array(4)].map((_, i) => (
))}
); } interface ErrorStateProps { message: string; } function ErrorState({ message }: ErrorStateProps) { const { t } = useTranslation("workflows"); return (

{t("drillIn.failedLoad")}

{message}

); } // ── Empty / no-selection state ──────────────────────────────────────────────── interface NoSessionStateProps { onSelectSession: (id: string) => void; } function NoSessionState({ onSelectSession }: NoSessionStateProps) { const { t } = useTranslation("workflows"); const tabs = [ { id: "tree" as Tab, label: t("drillIn.tabs.agentTree"), icon: , }, { id: "timeline" as Tab, label: t("drillIn.tabs.toolTimeline"), icon: , }, { id: "events" as Tab, label: t("drillIn.tabs.eventSequence"), icon: , }, ]; return (

{t("drillIn.noSessionSelected")}

{t("drillIn.noSessionDesc")}

{/* Preview tab pills */}
{tabs.map((tab) => (
{tab.icon} {tab.label}
))}
); } // ── Session header ──────────────────────────────────────────────────────────── interface SessionHeaderProps { drillIn: SessionDrillInData; onClose: () => void; activeTab: Tab; onTabChange: (t: Tab) => void; } function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHeaderProps) { const { t } = useTranslation("workflows"); const { session } = drillIn; return (

{session.name ?? session.id}

{formatModelName(session.model) ?? t("drillIn.unknownModel")} ·{" "} {t(`common:status.${session.status}`, { defaultValue: session.status })} {session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}

); } // ── Session Selector ────────────────────────────────────────────────────────── const PAGE_SIZE = 20; interface SessionSelectorProps { onSelectSession: (id: string) => void; } function SessionSelector({ onSelectSession }: SessionSelectorProps) { const { t } = useTranslation("workflows"); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [sessions, setSessions] = useState([]); const [allSessions, setAllSessions] = useState([]); const [offset, setOffset] = useState(0); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(false); const [allLoaded, setAllLoaded] = useState(false); const containerRef = useRef(null); const inputRef = useRef(null); const fetchPage = useCallback((pageOffset: number, replace: boolean) => { setLoading(true); api.sessions .list({ limit: PAGE_SIZE, offset: pageOffset }) .then(({ sessions: page }) => { setSessions((prev) => (replace ? page : [...prev, ...page])); setHasMore(page.length === PAGE_SIZE); setOffset(pageOffset + page.length); }) .catch(() => {}) .finally(() => setLoading(false)); }, []); // Load ALL sessions for search (once, lazily) const loadAllSessions = useCallback(() => { if (allLoaded) return; setAllLoaded(true); // Fetch large batch for search api.sessions .list({ limit: 5000, offset: 0 }) .then(({ sessions: all }) => setAllSessions(all)) .catch(() => {}); }, [allLoaded]); // Load first page when dropdown opens useEffect(() => { if (open && sessions.length === 0) { fetchPage(0, true); } }, [open, sessions.length, fetchPage]); // When user starts typing, load all sessions for search useEffect(() => { if (search.trim().length > 0) { loadAllSessions(); } }, [search, loadAllSessions]); // Close on outside click useEffect(() => { if (!open) return; function handleClick(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [open]); // When searching, filter across ALL sessions; otherwise show paginated const filtered = search.trim() ? (allSessions.length > 0 ? allSessions : sessions).filter((s) => { const q = search.toLowerCase(); return (s.name ?? "").toLowerCase().includes(q) || s.id.toLowerCase().includes(q); }) : sessions; function handleSelect(id: string) { setOpen(false); setSearch(""); onSelectSession(id); } function handleLoadMore() { fetchPage(offset, false); } return (
{/* Trigger row */}
{ setOpen(true); inputRef.current?.focus(); }} > setOpen(true)} onChange={(e) => { setSearch(e.target.value); setOpen(true); }} />
{/* Dropdown panel */} {open && (
{loading && sessions.length === 0 ? (
{[...Array(4)].map((_, i) => (
))}
) : filtered.length === 0 ? (

{search.trim() ? t("drillIn.noMatch") : t("drillIn.notFound")}

) : (
{filtered.map((s) => { const sc = statusColor(s.status); return ( ); })}
)} {/* Load more - only show when not filtering client-side */} {!search.trim() && hasMore && ( )}
)}
); } // ── Public component ────────────────────────────────────────────────────────── export interface SessionDrillInProps { sessionId: string | null; onClose: () => void; onSelectSession: (id: string) => void; } export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionDrillInProps) { const { t } = useTranslation(["workflows", "common"]); const [activeTab, setActiveTab] = useState("tree"); const [drillIn, setDrillIn] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!sessionId) { setDrillIn(null); setError(null); return; } let cancelled = false; setLoading(true); setError(null); setDrillIn(null); api.workflows .session(sessionId) .then((data) => { if (!cancelled) { setDrillIn(data); setActiveTab("tree"); } }) .catch((err: unknown) => { if (!cancelled) { const msg = err instanceof Error ? err.message : t("common:unexpectedError"); setError(msg); } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [sessionId, t]); // No session selected if (!sessionId) { return ; } if (loading) { return (
); } if (error) { return (

{sessionId}

); } if (!drillIn) return null; return (
{/* Tab content */} {activeTab === "tree" && } {activeTab === "timeline" && } {activeTab === "events" && }
); }