/** * @file SessionDetail.tsx * @description Displays detailed information about a specific session, including its agents, events, and cost breakdown, with real-time updates and an expandable agent hierarchy view. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../components/AgentCard` * - `../components/SessionOverview` * - `../components/conversation/ConversationView` * - `../components/StatusBadge` * - `../lib/types` * - `../components/EventDetail` * - `../components/EventFilters` * - `../components/EventFiltersInfo` * - `../components/Skeleton` * - `../lib/event-grouping` * * ## Public surface * - `SessionDetail` — 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. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **SessionDetail** * 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, type ReactNode } from "react"; import { Link, useParams, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { ArrowLeft, Bot, Clock, FolderOpen, Cpu, RefreshCw, DollarSign, ChevronDown, ChevronRight, GitBranch, MessageSquare, List, AlertCircle, Play, ExternalLink, Workflow, Hourglass, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { AgentCard } from "../components/AgentCard"; import { SessionOverview } from "../components/SessionOverview"; import { ConversationView } from "../components/conversation/ConversationView"; import { SessionStatusBadge, AgentStatusBadge, REASON_ICONS } from "../components/StatusBadge"; import { CopyButton } from "../components/event-views/primitives"; import { effectiveSessionStatus, isSessionAwaitingInput, sessionAwaitingReason, AWAITING_REASON_CONFIG, } from "../lib/types"; 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 { formatDateTime, formatDuration, fmtCostFull, timeAgo, formatModelName, } from "../lib/format"; import type { Session, Agent, DashboardEvent, CostResult, TranscriptInfo, WorkflowRun, } from "../lib/types"; import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel"; type DetailTab = "agents" | "conversation" | "timeline"; const EVENTS_INITIAL_BATCH = 50; const EVENTS_MORE_BATCH = 500; // Live-refresh bounds - see ActivityFeed for rationale. const EVENTS_MAX_REFRESH = 500; const EVENTS_REFRESH_DEBOUNCE_MS = 500; export function SessionDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { t } = useTranslation("sessions"); const { t: wfT } = useTranslation("workflows"); const [session, setSession] = useState(null); const [agents, setAgents] = useState([]); const [workflows, setWorkflows] = useState([]); const [events, setEvents] = useState([]); const [eventsTotal, setEventsTotal] = useState(0); const [eventsLoadingMore, setEventsLoadingMore] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTERS); const [cost, setCost] = useState(null); const [loading, setLoading] = useState(true); // True when this session is currently being driven by an in-flight Run // handle on /run. Drives the "Open in Run page" banner up top. const [isDashboardRun, setIsDashboardRun] = useState(false); const [error, setError] = useState(null); const [expandedAgents, setExpandedAgents] = useState>(() => { return new Set(); }); const [activeTab, setActiveTab] = useState("agents"); // Keep tabs mounted once visited so switching between them doesn't unmount/ // remount their subtrees (which causes a perceptible flash on click). const [visitedTabs, setVisitedTabs] = useState>(() => new Set(["agents"])); useEffect(() => { setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab))); }, [activeTab]); const [transcripts, setTranscripts] = useState([]); const [pendingTranscriptId, setPendingTranscriptId] = useState(null); const [transcriptNotFound, setTranscriptNotFound] = useState(false); const notFoundTimerRef = useRef | null>(null); const [expandedEvents, setExpandedEvents] = useState>(() => new Set()); function toggleEvent(id: number) { setExpandedEvents((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } // Refs let the websocket handler access latest state without re-subscribing. const eventApiParamsRef = useRef | null>(null); const eventsLoadedCountRef = useRef(0); const eventsRefreshTimerRef = useRef | null>(null); eventsLoadedCountRef.current = events.length; const goBack = useCallback(() => { const historyState = typeof window !== "undefined" ? (window.history.state as { idx?: number } | null) : null; if ((historyState?.idx ?? 0) > 0) { navigate(-1); return; } navigate("/sessions"); }, [navigate]); // Auto-dismiss not-found warning after 8 seconds useEffect(() => { if (transcriptNotFound) { notFoundTimerRef.current = setTimeout(() => setTranscriptNotFound(false), 8000); return () => { if (notFoundTimerRef.current) clearTimeout(notFoundTimerRef.current); }; } }, [transcriptNotFound]); // Probe /api/run to see whether THIS session is currently being driven by // an in-flight Run handle. If so, surface a banner so the user can hop // back to /run instead of viewing this as a passive transcript. useEffect(() => { if (!id) return; let cancelled = false; const probe = () => { // Defensive: tests mock the api module without a `run` namespace. if (!api.run || typeof api.run.list !== "function") return; api.run .list() .then((r) => { if (cancelled) return; const live = r.items.some((h) => h.sessionId === id && h.status === "running"); setIsDashboardRun(live); }) .catch(() => undefined); }; probe(); const t = setInterval(probe, 10000); const unsub = eventBus.subscribe((msg) => { if (msg.type === "run_status") probe(); }); return () => { cancelled = true; clearInterval(t); unsub(); }; }, [id]); const load = useCallback(async () => { if (!id) return; try { const [data, costData] = await Promise.all([ api.sessions.get(id), api.pricing.sessionCost(id).catch(() => null), ]); setSession(data.session); setAgents(data.agents); setWorkflows(data.workflows || []); setCost(costData); setError(null); } catch (err) { setError(err instanceof Error ? err.message : t("detail.failedLoad")); } finally { setLoading(false); } }, [id, t]); useEffect(() => { load(); }, [load]); // Load transcripts list (for Agent → Conversation navigation ID mapping) useEffect(() => { if (!id) return; let cancelled = false; api.sessions .transcripts(id) .then((result) => { if (!cancelled) setTranscripts(result.transcripts); }) .catch(() => {}); return () => { cancelled = true; }; }, [id]); // Navigate to Conversation tab and select the matching transcript when clicking an agent const navigateToAgentConversation = useCallback( (agent: Agent) => { // Clear any previous not-found warning setTranscriptNotFound(false); const findTranscriptId = (ts: TranscriptInfo[]): string | null => { // 1. Exact match via db_agent_id (most reliable) const exactMatch = ts.find((t) => t.db_agent_id === agent.id); if (exactMatch) return exactMatch.id; // 2. Main agent fallback if (agent.type === "main") return "main"; // 3. Fallback: match by subagent_type or type, then narrow by name let candidates = ts.filter((t) => t.type !== "main"); if (agent.subagent_type) { const byType = candidates.filter( (t) => t.subagent_type === agent.subagent_type || t.type === agent.subagent_type ); if (byType.length > 0) candidates = byType; } if (agent.name && candidates.length > 1) { const byName = candidates.filter((t) => t.name === agent.name); if (byName.length > 0) candidates = byName; } if (candidates.length === 1) return candidates[0]!.id; return null; }; // Always switch to the conversation tab - the user clicked a leaf agent // and expects to see its conversation. If no exact transcript match is // found, the tab still opens and the not-found banner explains why. setActiveTab("conversation"); const transcriptId = findTranscriptId(transcripts); if (transcriptId) { setPendingTranscriptId(transcriptId); } else if (transcripts.length === 0 && id) { // Transcripts not loaded yet - fetch them and retry. The tab is // already showing; this just selects the right transcript when ready. api.sessions .transcripts(id) .then((result) => { setTranscripts(result.transcripts); const freshId = findTranscriptId(result.transcripts); if (freshId) { setPendingTranscriptId(freshId); } else { setTranscriptNotFound(true); } }) .catch(() => { setTranscriptNotFound(true); }); } else { // Transcripts are loaded but no specific match for this agent. setTranscriptNotFound(true); } }, [transcripts, id] ); // Compute compaction labels: "#1", "#2", etc. based on started_at order const compactionLabels = useMemo(() => { const map = new Map(); const compactions = agents .filter((a) => a.subagent_type === "compaction") .sort((a, b) => (a.started_at || "").localeCompare(b.started_at || "")); compactions.forEach((a, i) => { const time = a.started_at ? new Date(a.started_at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", }) : ""; map.set(a.id, `#${i + 1}${time ? ` · ${time}` : ""}`); }); return map; }, [agents]); // Event list is fetched separately from the session metadata so it can // respect the user's filters and use the server-driven pagination. Status // presets expand into event_type values and merge with any explicit // event_type selection. const eventApiParams = useMemo(() => { if (!id) return null; const statusExpanded = expandStatusToEventTypes(filters.status); const eventTypeMerged = Array.from(new Set([...filters.event_type, ...statusExpanded])); return { session_id: [id], 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, q: filters.q || undefined, from: filters.from ? new Date(filters.from).toISOString() : undefined, to: filters.to ? new Date(filters.to).toISOString() : undefined, }; }, [id, filters]); eventApiParamsRef.current = eventApiParams; // Build an AgentInfo map from the already-fetched agents list so rows can // render the subagent pill (subagent_type) without an extra fetch. const agentInfoById = useMemo(() => { const map = new Map(); 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, }); } return map; }, [agents]); // Single-entry session-name lookup so EventDetail can surface the session // label above the raw id, mirroring the agentInfoById pattern. Falls back // to "Session abcdefgh" when session.name is null/empty so the row always // shows *something* identifiable - matches the header's display logic. const sessionNameById = useMemo(() => { const map = new Map(); if (session?.id) { const label = session.name?.trim() || `Session ${session.id.slice(0, 8)}`; map.set(session.id, label); } return map; }, [session?.id, session?.name]); // Precompute project per event so flat-row rendering doesn't re-parse JSON. const projectByEventId = useMemo(() => { const map = new Map(); for (const e of events) map.set(e.id, projectFromEvent(e)); return map; }, [events]); const loadEvents = useCallback(async () => { if (!eventApiParams) return; try { const { events: data, total } = await api.events.list({ ...eventApiParams, limit: EVENTS_INITIAL_BATCH, offset: 0, }); setEvents(data); setEventsTotal(total); } catch (err) { console.error("Failed to load session events:", err); } }, [eventApiParams]); useEffect(() => { loadEvents(); }, [loadEvents]); const loadMoreEvents = useCallback(async () => { if (!eventApiParams) return; setEventsLoadingMore(true); try { const { events: data, total } = await api.events.list({ ...eventApiParams, limit: EVENTS_MORE_BATCH, offset: events.length, }); setEvents((prev) => [...prev, ...data]); setEventsTotal(total); } catch (err) { console.error("Failed to load more session events:", err); } finally { setEventsLoadingMore(false); } }, [eventApiParams, events.length]); // Auto-expand agents that have working subagents (at any depth) useEffect(() => { const parentsWithActiveChildren = new Set(); for (const a of agents) { if (a.parent_agent_id && a.status === "working") { parentsWithActiveChildren.add(a.parent_agent_id); } } if (parentsWithActiveChildren.size > 0) { const agentMap = new Map(agents.map((a) => [a.id, a])); const toExpand = new Set(); for (const pid of parentsWithActiveChildren) { let cur = pid; while (cur) { toExpand.add(cur); const parent = agentMap.get(cur); cur = parent?.parent_agent_id ?? ""; } } setExpandedAgents((prev) => new Set([...prev, ...toExpand])); } }, [agents]); // Pagination-preserving filtered refresh, mirrors ActivityFeed's behavior. const refreshEventsWithPagination = useCallback(async () => { const params = eventApiParamsRef.current; if (!params) return; const target = Math.max( eventsLoadedCountRef.current || EVENTS_INITIAL_BATCH, EVENTS_INITIAL_BATCH ); const size = Math.min(target, EVENTS_MAX_REFRESH); try { const { events: data, total } = await api.events.list({ ...params, limit: size, offset: 0, }); setEvents(data); setEventsTotal(total); } catch (err) { console.error("Failed to refresh session events:", err); } }, []); useEffect(() => { const unsubscribe = eventBus.subscribe((msg) => { if (isRemoteDataRefreshMessage(msg)) { load(); if (eventsRefreshTimerRef.current) clearTimeout(eventsRefreshTimerRef.current); eventsRefreshTimerRef.current = setTimeout(() => { eventsRefreshTimerRef.current = null; refreshEventsWithPagination(); }, EVENTS_REFRESH_DEBOUNCE_MS); return; } if ( msg.type === "agent_created" || msg.type === "agent_updated" || msg.type === "session_updated" ) { load(); } if (msg.type === "new_event") { // Debounce bursts into one filter-aware refetch that preserves the // current "Load more" pagination size. if (eventsRefreshTimerRef.current) clearTimeout(eventsRefreshTimerRef.current); eventsRefreshTimerRef.current = setTimeout(() => { eventsRefreshTimerRef.current = null; refreshEventsWithPagination(); }, EVENTS_REFRESH_DEBOUNCE_MS); } }); return () => { unsubscribe(); if (eventsRefreshTimerRef.current) { clearTimeout(eventsRefreshTimerRef.current); eventsRefreshTimerRef.current = null; } }; }, [load, refreshEventsWithPagination]); if (loading) { return (
{Array.from({ length: 4 }).map((_, i) => (
))}
{Array.from({ length: 2 }).map((_, i) => (
))}
); } if (error || !session) { return (

{error || t("detail.notFound")}

); } return (
{/* Header */}

{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}

{session.id.slice(0, 16)} {session.model && ( {formatModelName(session.model)} )} {formatDateTime(session.started_at)} {session.ended_at && ( ({formatDuration(session.started_at, session.ended_at)}) )} {cost && cost.total_cost > 0 && ( {fmtCostFull(cost.total_cost).slice(1)} )}
{session.cwd && (
{session.cwd}
)}
{/* Waiting-for-input callout: WHY the session sits in the yellow Waiting state (awaiting_reason) and for how long. Reason-specific icon/label when the server sent a known reason; a generic hourglass fallback for legacy rows that predate the awaiting_reason column. Urgent reasons (permission prompt / interruption) render hotter amber than the calm idle-between-turns ones. */} {isSessionAwaitingInput(session) && (() => { const reason = sessionAwaitingReason(session); const cfg = reason ? AWAITING_REASON_CONFIG[reason] : null; const ReasonIcon = reason ? REASON_ICONS[reason] : Hourglass; const urgent = cfg?.urgent ?? false; return (
{t("detail.waitingBanner.title")} {cfg && ( {" · "} {t(cfg.labelKey)} )}
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
{session.awaiting_input_since && ( )}
); })()} {isDashboardRun && (
{t("detail.dashboardRun.title", "This session is being driven from the Run page")}
{t( "detail.dashboardRun.body", "Send follow-ups, watch streaming output, or stop the run from there." )}
)} {/* Tab Navigation */}
{/* Tab Content */} {transcriptNotFound && (
{t("detail.transcriptNotFound")}
)} {visitedTabs.has("agents") && ( )} {visitedTabs.has("conversation") && ( )} {visitedTabs.has("timeline") && ( )}
); }