/** * @file AgentCard.tsx * @description Defines the AgentCard component that displays a summary of an agent's information, including its name, status, task, current tool, and timestamps. The card is clickable and navigates to the agent's session details when clicked; a right-click opens `SessionRowMenu`. It also visually distinguishes active agents with a border highlight. * @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 * - `./StatusBadge` * - `../lib/types` * - `../lib/format` * * ## Public surface * - `AgentCard` — 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). * ----------------------------------------------------------------------------- * **AgentCard** * 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 { useTranslation } from "react-i18next"; import { Bot, GitBranch, Clock, Wrench, Cpu, Coins } from "lucide-react"; import { memo, useMemo } from "react"; import { Link, useNavigate } from "react-router-dom"; import { AgentStatusBadge } from "./StatusBadge"; import { useContextMenu } from "./ContextMenu"; import { SessionRowMenu } from "./SessionRowMenu"; import { effectiveAgentStatus, isAgentAwaitingInput, agentAwaitingReason } from "../lib/types"; import type { Agent, Session } from "../lib/types"; import { formatDuration, timeAgo, formatModelName, pathBasename, fmtCost } from "../lib/format"; /** * Display name for a main agent, swapping its auto-generated placeholder for the * real session title when one exists. Main agents are created as * ` - `, where the placeholder is either `Session ` * (live hooks) or ` - ` (import / background sync). Replacing * everything after the first ` - ` covers BOTH formats — the older * `replace(/Session [0-9a-f]{8}/)` only matched the hook form, so imported * sessions kept showing ` - ` even after their title was known. */ function mainAgentDisplayName(agentName: string, realSessionName: string): string { if (!realSessionName) return agentName; const sep = agentName.indexOf(" - "); return sep >= 0 ? `${agentName.slice(0, sep)} - ${realSessionName}` : agentName; } interface AgentCardProps { agent: Agent; /** Optional session data for richer main-agent rendering (model, cwd, * cost). Subagent display ignores this. When omitted, the card falls * back to the original minimal layout. */ session?: Session; label?: string; onClick?: () => void; } /** Memoized: the Dashboard re-renders on every live update, and a card only * changes with its own agent/session. */ export const AgentCard = memo(function AgentCard({ agent, session, label, onClick, }: AgentCardProps) { const navigate = useNavigate(); const { t } = useTranslation("kanban"); const ctx = useContextMenu(); const isWaiting = agent.status === "waiting" || isAgentAwaitingInput(agent); const status = effectiveAgentStatus(agent); const isActive = agent.status === "working"; const isMain = agent.type === "main"; // Session-level metadata applies to every card in the session - main and // subagents alike. Subtitle differs by type: main uses model+cwd (its // auto-generated name carries no info), subagents stick with their // subagent_type label (more useful than repeating the session model). const model = formatModelName(session?.model); const cwdBase = pathBasename(session?.cwd); // Cost shown on the card is scoped to what the card represents: a main agent's // card stands in for the whole session, so it shows the session total; a // subagent's card shows that subagent's OWN cost (server-computed from its // token buckets). Showing the session total on a subagent card is misleading — // it reads as if that one subagent cost the whole session's spend. A subagent // with no recorded usage shows no cost (the cost > 0 guard below hides it), // which is truthful rather than misleading. const cost = isMain ? typeof session?.cost === "number" ? session.cost : 0 : typeof agent.cost === "number" ? agent.cost : 0; // Real (user-given) session name - the auto-generated "Session " // fallback carries no extra info next to the ID, so it is suppressed. const sessionName = session?.name?.trim() || ""; const realSessionName = /^Session [0-9a-f]{8}$/i.test(sessionName) ? "" : sessionName; // A subagent's own model lives in its metadata (resolved from its transcript, // not the parent session's — see issue #185). Use it everywhere this card // shows a model so a Haiku QA agent under an Opus orchestrator reads as // Haiku, not Opus. Falls back to the session model only for the main agent. const subagentModel = useMemo(() => { if (isMain || !agent.metadata) return null; try { const parsed = JSON.parse(agent.metadata) as { model?: string }; return parsed?.model ? formatModelName(parsed.model) : null; } catch { return null; } }, [isMain, agent.metadata]); // The model badge (footer) must reflect THIS card's agent: the session model // for main, the subagent's own model for subagents. const displayModel = isMain ? model : subagentModel; // Model now lives in the footer badge, so the subtitle carries project // context instead: main shows cwd + how many agents the session spawned + // how many turns it has run; subagents show their type + the project they ran // in. (No model here — that would duplicate the footer badge, which is what // main cards used to do.) const agentCount = typeof session?.agent_count === "number" ? session.agent_count : 0; // agent_count includes the main agent itself. Show how many SUBAGENTS the // session spawned instead, so this reconciles with the "Active Subagents" // dashboard stat (which excludes main agents) — otherwise a card reading // "29 agents" looks like it should equal a 29-subagent stat when the session // actually has 28 subagents + 1 main. const subagentCount = Math.max(0, agentCount - 1); const sessionTurns = useMemo(() => { if (!isMain || !session?.metadata) return 0; try { const m = JSON.parse(session.metadata) as { turn_count?: number }; return typeof m?.turn_count === "number" ? m.turn_count : 0; } catch { return 0; } }, [isMain, session?.metadata]); const subtitle = isMain ? [ cwdBase, subagentCount > 0 ? t("kanban:session.subagentSummary", { count: subagentCount }) : null, sessionTurns > 0 ? t("kanban:session.turnSummary", { count: sessionTurns }) : null, ] .filter(Boolean) .join(" · ") || null : [label || agent.subagent_type, cwdBase].filter(Boolean).join(" · ") || null; const sessionHref = `/sessions/${agent.session_id}`; // The whole card is clickable with the mouse; the title is the focusable // control (a real link, or a button when the caller handles the click). function handleClick(e: React.MouseEvent) { if ((e.target as HTMLElement).closest("a,button")) return; if (onClick) onClick(); else navigate(sessionHref); } const titleText = isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name; const titleClass = "block text-sm font-medium text-fg-secondary truncate text-left max-w-full rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"; return (
ctx.open(e, true)} className={`card-hover p-4 cursor-pointer overflow-hidden ${ isWaiting ? "border-l-2 border-l-status-warning/60" : isActive ? "border-l-2 border-l-status-success/50" : "" }`} >
{isMain ? : }
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session 229d93fd" or "Main Agent - work - e3f8e613") swap the placeholder for the real session name when one exists; custom (sub)agent names are left untouched. */} {onClick ? ( ) : ( {titleText} )} {subtitle &&

{subtitle}

}
{/* compact: cards are narrow — inline reason chip would squeeze the title, so the reason stays hover-tooltip-only here. */}
{agent.task && (

{agent.task}

)}
{agent.current_tool && ( {agent.current_tool} )} {/* Model badge - shown on every card when no tool is currently running (avoids clutter on actively-running agents that already display the running tool name). Uses the agent's OWN model: session model for main, the subagent's resolved model otherwise. */} {displayModel && !agent.current_tool && ( {displayModel} )} {cost > 0 && ( {fmtCost(cost)} )} {agent.ended_at ? ( <> {t("ran")} {formatDuration(agent.started_at, agent.ended_at)} {timeAgo(agent.ended_at)} ) : ( {timeAgo(agent.updated_at || agent.started_at)} )} {realSessionName && {realSessionName} ·} {agent.session_id.slice(0, 8)}
{ctx.menu && ( )}
); });