Files
AgentHub/client/src/pages/SessionDetail.tsx
T
nntrivi2001 8ea40260ad feat(ui): terminal, charts and low-contrast text follow the selected theme
- The lane terminal (xterm) takes its palette from the selected DARK theme —
  it stays dark in light mode, as a terminal should — and repaints live when
  the theme changes (`client/src/lib/terminalTheme.ts`).
- Charts on the Dashboard and Workflows pages draw their grid, axes, labels
  and tooltips from theme tokens instead of hardcoded near-black/white, and
  the activity heatmap ramps from the page surface to the accent, so it reads
  on every theme instead of going near-black on light ones.
- Status/brand text that only worked on dark backgrounds
  (`text-*-300/400`) gets a darker light-mode shade (`text-*-700
  dark:text-*-300`) across sessions, conversations, workflows, config and
  settings. Categorical chart colours are kept.
2026-09-24 09:50:44 +07:00

1170 lines
48 KiB
TypeScript

/**
* @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ĩ <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.
*
* ## 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<Session | null>(null);
const [agents, setAgents] = useState<Agent[]>([]);
const [workflows, setWorkflows] = useState<WorkflowRun[]>([]);
const [events, setEvents] = useState<DashboardEvent[]>([]);
const [eventsTotal, setEventsTotal] = useState(0);
const [eventsLoadingMore, setEventsLoadingMore] = useState(false);
const [filters, setFilters] = useState<EventFiltersValue>(EMPTY_FILTERS);
const [cost, setCost] = useState<CostResult | null>(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<string | null>(null);
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(() => {
return new Set<string>();
});
const [activeTab, setActiveTab] = useState<DetailTab>("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<Set<DetailTab>>(() => new Set(["agents"]));
useEffect(() => {
setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab)));
}, [activeTab]);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [pendingTranscriptId, setPendingTranscriptId] = useState<string | null>(null);
const [transcriptNotFound, setTranscriptNotFound] = useState(false);
const notFoundTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => 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<Record<string, unknown> | null>(null);
const eventsLoadedCountRef = useRef(0);
const eventsRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, string>();
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<string>([...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<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,
});
}
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<string, string>();
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<number, string | null>();
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<string>();
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<string>();
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 (
<div className="animate-fade-in space-y-8" aria-busy="true">
<div className="flex items-start gap-4">
<Skeleton className="w-8 h-8" rounded="md" />
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-5 w-16" rounded="full" />
</div>
<div className="flex flex-wrap gap-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-32" />
<Skeleton className="h-5 w-24" />
</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="card p-5 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-7 w-24" />
</div>
))}
</div>
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="card p-5 space-y-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-11/12" />
<Skeleton className="h-3 w-5/6" />
</div>
))}
</div>
);
}
if (error || !session) {
return (
<div className="text-center py-20">
<p className="text-status-danger mb-2">{error || t("detail.notFound")}</p>
<button onClick={goBack} className="btn-ghost mt-4">
<ArrowLeft className="w-4 h-4" /> {t("detail.backToSessions")}
</button>
</div>
);
}
return (
<div className="animate-fade-in space-y-8">
{/* Header */}
<div className="flex items-start gap-4">
<button onClick={goBack} className="btn-ghost mt-1">
<ArrowLeft className="w-4 h-4" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h2 className="text-xl font-semibold text-fg-primary">
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</h2>
<SessionStatusBadge
status={effectiveSessionStatus(session)}
reason={sessionAwaitingReason(session)}
/>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1">
<span className="inline-flex items-center gap-1 text-xs text-fg-muted font-mono bg-surface-2 px-2 py-1 rounded">
<span title={session.id}>{session.id.slice(0, 16)}</span>
<CopyButton text={session.id} />
</span>
{session.model && (
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-2 px-2 py-1 rounded">
<Cpu className="w-3 h-3 text-fg-muted" />
{formatModelName(session.model)}
</span>
)}
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-2 px-2 py-1 rounded">
<Clock className="w-3 h-3 text-fg-muted" />
{formatDateTime(session.started_at)}
{session.ended_at && (
<span className="text-fg-muted ml-1">
({formatDuration(session.started_at, session.ended_at)})
</span>
)}
</span>
{cost && cost.total_cost > 0 && (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 px-2 py-1 rounded">
<DollarSign className="w-3 h-3" />
{fmtCostFull(cost.total_cost).slice(1)}
</span>
)}
</div>
{session.cwd && (
<div className="flex items-center gap-1.5 text-xs text-fg-muted mt-2 min-w-0">
<FolderOpen className="w-3 h-3 flex-shrink-0" />
<span className="font-mono truncate" title={session.cwd}>
{session.cwd}
</span>
<CopyButton text={session.cwd} />
</div>
)}
</div>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" />
</button>
</div>
{/* 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 (
<div
className={`flex items-center gap-3 rounded-lg border px-4 py-2.5 ${
urgent
? "border-status-warning/40 bg-status-warning/[0.08]"
: "border-yellow-500/25 bg-yellow-500/[0.05]"
}`}
>
<span
className={`w-7 h-7 rounded-md inline-flex items-center justify-center flex-shrink-0 border ${
urgent
? "bg-status-warning/15 border-status-warning/30"
: "bg-yellow-500/10 border-yellow-500/25"
}`}
>
<ReasonIcon
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-yellow-700 dark:text-yellow-300"}`}
/>
</span>
<div className="flex-1 min-w-0">
<div
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-yellow-800 dark:text-yellow-200"}`}
>
{t("detail.waitingBanner.title")}
{cfg && (
<span
className={
urgent
? "text-status-warning/90"
: "text-yellow-700/80 dark:text-yellow-300/80"
}
>
{" · "}
{t(cfg.labelKey)}
</span>
)}
</div>
<div
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-yellow-700/60 dark:text-yellow-400/60"}`}
>
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
</div>
</div>
{session.awaiting_input_since && (
<span
className={`text-[11px] flex-shrink-0 flex items-center gap-1.5 ${
urgent ? "text-status-warning/80" : "text-yellow-700/70 dark:text-yellow-400/70"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full animate-pulse-dot ${
urgent ? "bg-status-warning" : "bg-yellow-400"
}`}
aria-hidden="true"
/>
{timeAgo(session.awaiting_input_since)}
</span>
)}
</div>
);
})()}
{isDashboardRun && (
<Link
to={`/run?session=${encodeURIComponent(id || "")}`}
className="flex items-center gap-3 rounded-lg border border-status-success/30 bg-status-success/[0.06] hover:bg-status-success/[0.12] hover:border-status-success/50 px-4 py-2.5 transition-colors group"
>
<span className="w-7 h-7 rounded-md bg-status-success/15 border border-status-success/30 inline-flex items-center justify-center flex-shrink-0">
<Play className="w-3.5 h-3.5 text-status-success" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-status-success">
{t("detail.dashboardRun.title", "This session is being driven from the Run page")}
</div>
<div className="text-[11px] text-status-success/70">
{t(
"detail.dashboardRun.body",
"Send follow-ups, watch streaming output, or stop the run from there."
)}
</div>
</div>
<ExternalLink className="w-4 h-4 text-status-success/70 group-hover:text-status-success flex-shrink-0" />
</Link>
)}
{/* Tab Navigation */}
<div className="flex items-center gap-1 border-b border-border">
<button
onClick={() => {
setActiveTab("agents");
setTranscriptNotFound(false);
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "agents"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<Bot className="w-4 h-4" />
{t("detail.agents")} ({agents.length})
</button>
<button
onClick={() => {
setActiveTab("conversation");
setTranscriptNotFound(false);
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "conversation"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<MessageSquare className="w-4 h-4" />
{t("detail.conversation")}
</button>
<button
onClick={() => {
setActiveTab("timeline");
setTranscriptNotFound(false);
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "timeline"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<List className="w-4 h-4" />
{t("detail.timeline", { shown: events.length, total: eventsTotal })}
</button>
</div>
{/* Tab Content */}
{transcriptNotFound && (
<div className="flex items-center gap-2 px-4 py-2.5 mb-3 text-sm text-status-warning bg-status-warning/10 border border-status-warning/20 rounded-lg">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("detail.transcriptNotFound")}</span>
<button
onClick={() => setTranscriptNotFound(false)}
className="ml-auto text-status-warning/60 hover:text-status-warning transition-colors"
>
<List className="w-3.5 h-3.5" />
</button>
</div>
)}
{visitedTabs.has("agents") && (
<div hidden={activeTab !== "agents"}>
<SessionOverview session={session} agents={agents} />
{workflows.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Workflow className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
{wfT("runs.sessionTitle")}
<span className="text-fg-muted font-mono">· {workflows.length}</span>
</h3>
<WorkflowRunsPanel runs={workflows} hideSessionLink />
</div>
)}
{agents.length === 0 ? (
<p className="text-sm text-fg-muted">{t("detail.noAgents")}</p>
) : (
<>
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Bot className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
{t("detail.agents")}
<span className="text-fg-muted font-mono">· {agents.length}</span>
</h3>
<div className="space-y-2" data-testid="agent-tree">
{(() => {
// Build parent→children map for the full tree (works at any depth)
const agentMap = new Map(agents.map((a) => [a.id, a]));
const childrenByParent = new Map<string, Agent[]>();
const rootAgents: Agent[] = [];
for (const a of agents) {
if (a.parent_agent_id && agentMap.has(a.parent_agent_id)) {
const list = childrenByParent.get(a.parent_agent_id) || [];
list.push(a);
childrenByParent.set(a.parent_agent_id, list);
} else if (!a.parent_agent_id || !agentMap.has(a.parent_agent_id)) {
rootAgents.push(a);
}
}
// Sort roots and children by started_at ascending (chronological order)
rootAgents.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""));
for (const key of childrenByParent.keys()) {
childrenByParent
.get(key)!
.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""));
}
// Count all descendants (recursive) for collapsed badge.
// `seen` guards against a cyclic parent_agent_id (corrupt data)
// that would otherwise recurse forever and crash the page.
function countDescendants(id: string, seen = new Set<string>()): number {
if (seen.has(id)) return 0;
seen.add(id);
const kids = childrenByParent.get(id) || [];
return kids.reduce((sum, k) => sum + 1 + countDescendants(k.id, seen), 0);
}
// Recursive agent node renderer. `ancestors` guards against a
// cyclic parent_agent_id so a bad link can't stack-overflow.
function renderAgentNode(
agent: Agent,
depth: number,
ancestors: Set<string> = new Set()
): ReactNode {
if (ancestors.has(agent.id)) return null;
const childAncestors = new Set(ancestors).add(agent.id);
const children = childrenByParent.get(agent.id) || [];
const isExpanded = expandedAgents.has(agent.id);
const hasChildren = children.length > 0;
const isSubagent = depth > 0;
const totalDesc = hasChildren ? countDescendants(agent.id) : 0;
const toggleExpanded = () =>
setExpandedAgents((prev) => {
const next = new Set(prev);
if (next.has(agent.id)) next.delete(agent.id);
else next.add(agent.id);
return next;
});
return (
<div key={agent.id}>
<div className="flex items-center gap-1 min-w-0">
{hasChildren && (
<button
onClick={toggleExpanded}
className="p-1 text-fg-muted hover:text-fg-secondary transition-colors flex-shrink-0"
aria-label={isExpanded ? "Collapse subagents" : "Expand subagents"}
aria-expanded={isExpanded}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
)}
{isSubagent && !hasChildren && <span className="w-6 flex-shrink-0" />}
{isSubagent && (
<GitBranch className="w-3 h-3 text-violet-700 dark:text-violet-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<AgentCard
agent={agent}
// The /api/sessions/:id session row has no `cost`
// column (cost is loaded separately into `cost`
// state), so inject the computed session total here
// — otherwise the MAIN agent card, which shows
// session.cost, renders no cost on this page.
// Subagent cards ignore session.cost (they use
// agent.cost), so this only affects the main card.
session={
session
? { ...session, cost: cost?.total_cost ?? session.cost }
: undefined
}
label={compactionLabels.get(agent.id)}
// Card click always opens the agent conversation —
// same behavior whether or not it has children.
// Expand/collapse of the subagent list is handled
// solely by the chevron button, so clicking a
// parent (incl. the main agent) no longer toggles.
onClick={() => navigateToAgentConversation(agent)}
/>
</div>
</div>
{/* Recursive children (collapsible) */}
{hasChildren && isExpanded && (
<div className="ml-6 mt-1 space-y-1 border-l-2 border-violet-500/20 pl-3">
{children.map((child) =>
renderAgentNode(child, depth + 1, childAncestors)
)}
</div>
)}
{/* Descendant count badge when collapsed */}
{hasChildren && !isExpanded && (
<button
onClick={() =>
setExpandedAgents((prev) => new Set([...prev, agent.id]))
}
className="ml-7 mt-1 text-[11px] text-violet-700 dark:text-violet-400 hover:text-violet-700 dark:hover:text-violet-300 transition-colors"
>
{t("common:subagent_label", { count: totalDesc })}
</button>
)}
</div>
);
}
// Separate true orphans (subagent whose parent_agent_id references a missing agent)
const orphans = rootAgents.filter(
(a) =>
a.type === "subagent" && a.parent_agent_id && !agentMap.has(a.parent_agent_id)
);
const roots = rootAgents.filter(
(a) =>
!(
a.type === "subagent" &&
a.parent_agent_id &&
!agentMap.has(a.parent_agent_id)
)
);
return (
<>
{roots.map((agent) => renderAgentNode(agent, 0))}
{/* Orphaned subagents */}
{orphans.length > 0 && (
<div className="mt-4">
<p className="text-[11px] text-fg-muted mb-2 uppercase tracking-wider">
{t("detail.unparented")}
</p>
<div className="space-y-1">
{orphans.map((agent) => renderAgentNode(agent, 1))}
</div>
</div>
)}
</>
);
})()}
</div>
</>
)}
{/* Cost Breakdown - shown under Agents tab */}
{cost && cost.breakdown.length > 0 && cost.total_cost > 0 && (
<div className="mt-8">
<h3 className="text-sm font-medium text-fg-secondary mb-4 flex items-center gap-2">
<DollarSign className="w-4 h-4" />
{t("detail.costBreakdown")}
</h3>
<div className="card overflow-x-auto">
<table className="w-full min-w-[600px]">
<thead>
<tr className="border-b border-border text-left">
<th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("common:cost.model")}
</th>
<th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.input")}
</th>
<th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.output")}
</th>
<th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.cacheRead")}
</th>
<th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:token.cacheWrite")}
</th>
<th className="px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
{t("common:cost.cost")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{cost.breakdown.map((row) => (
<tr key={row.model} className="hover:bg-surface-4 transition-colors">
<td className="px-5 py-2.5 text-sm font-mono text-fg-secondary">
{formatModelName(row.model)}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary text-right font-mono">
{row.input_tokens.toLocaleString()}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary text-right font-mono">
{row.output_tokens.toLocaleString()}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary text-right font-mono">
{row.cache_read_tokens.toLocaleString()}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary text-right font-mono">
{row.cache_write_tokens.toLocaleString()}
</td>
<td className="px-5 py-2.5 text-sm text-status-success text-right font-mono font-medium">
{fmtCostFull(row.cost, 4)}
</td>
</tr>
))}
<tr className="bg-surface-2">
<td className="px-5 py-2.5 text-sm font-medium text-fg-secondary" colSpan={5}>
{t("common:total")}
</td>
<td className="px-5 py-2.5 text-sm text-status-success text-right font-mono font-semibold">
{fmtCostFull(cost.total_cost, 4)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{visitedTabs.has("conversation") && (
<div hidden={activeTab !== "conversation"}>
<ConversationView sessionId={session.id} initialTranscriptId={pendingTranscriptId} />
</div>
)}
{visitedTabs.has("timeline") && (
<div hidden={activeTab !== "timeline"}>
<div className="mb-3">
<EventFiltersInfo />
</div>
<div className="mb-3">
<EventFilters
value={filters}
onChange={setFilters}
hideSessionFilter
agentOptions={agents.map((a) => ({ id: a.id, label: a.name || a.id }))}
/>
</div>
{events.length === 0 ? (
<p className="text-sm text-fg-muted">
{isEmptyFilters(filters) ? t("detail.noEvents") : t("common:eventFilters.noResults")}
</p>
) : (
<div className="card overflow-hidden">
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
{events.map((event, i) => {
const key = event.id ?? i;
const isOpen = event.id != null && expandedEvents.has(event.id);
return (
<div key={key}>
<button
type="button"
onClick={() => event.id != null && toggleEvent(event.id)}
aria-expanded={isOpen}
aria-label={
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
}
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
>
<span
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
aria-hidden="true"
>
▶
</span>
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
{timeAgo(event.created_at)}
</div>
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
{(() => {
// Session is implicit on this page - project is
// still shown so the row identifies the working
// directory when you share / search. Events without
// their own cwd (e.g. TurnDuration) fall back to the
// session's cwd so every row carries the dir prefix.
const project =
projectByEventId.get(event.id) ?? projectFromCwd(session?.cwd) ?? null;
const origin = buildOriginLabel(
project,
null,
agentOriginLabel(event.agent_id, agentInfoById)
);
return (
<span className="text-sm text-fg-secondary flex-1 truncate">
{origin && (
<span
className="text-fg-muted mr-1"
title={event.agent_id ?? undefined}
>
{origin} ·
</span>
)}
{buildEventTitle(event)}
</span>
);
})()}
{event.tool_name && (
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
{event.tool_name}
</span>
)}
</button>
{isOpen && (
<EventDetail
event={event}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
)}
</div>
);
})}
</div>
</div>
)}
{events.length < eventsTotal && (
<div className="flex items-center justify-between mt-3 px-1">
<span className="text-xs text-fg-muted">
{t("common:eventFilters.showing", { shown: events.length, total: eventsTotal })}
</span>
<button
type="button"
onClick={loadMoreEvents}
disabled={eventsLoadingMore}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-accent/15 text-accent hover:bg-accent/25 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{eventsLoadingMore
? t("common:eventFilters.loading")
: t("common:eventFilters.loadMore")}
</button>
</div>
)}
</div>
)}
</div>
);
}