import * as React from "react"; import Markdown from "react-markdown"; import type { AgentConfigInfo, AgentEvent, AgentInfo, AgentStatusValue, } from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents"; import type { AgentCostEntry, TestCaseResult, TestResultsResponse, TokenCostResponse, } from "../api/client"; import { api } from "../api/client"; const { useCallback, useEffect, useRef, useState } = React; const STAGE_LABELS: Record = { backlog: "Backlog", current: "Current", qa: "QA", merge: "To Merge", done: "Done", archived: "Archived", }; const STATUS_COLORS: Record = { running: "#3fb950", pending: "#e3b341", completed: "#aaa", failed: "#f85149", }; interface WorkItemDetailPanelProps { storyId: string; pipelineVersion: number; onClose: () => void; /** True when the item is in QA and awaiting human review. */ reviewHold?: boolean; } function TestCaseRow({ tc }: { tc: TestCaseResult }) { const isPassing = tc.status === "pass"; return (
{isPassing ? "PASS" : "FAIL"} {tc.name}
{tc.details && (
{tc.details}
)}
); } function TestSection({ title, tests, testId, }: { title: string; tests: TestCaseResult[]; testId: string; }) { const passCount = tests.filter((t) => t.status === "pass").length; const failCount = tests.length - passCount; return (
{title} ({passCount} passed, {failCount} failed)
{tests.length === 0 ? (
No tests recorded
) : ( tests.map((tc) => ) )}
); } export function WorkItemDetailPanel({ storyId, pipelineVersion, onClose, reviewHold: _reviewHold, }: WorkItemDetailPanelProps) { const [content, setContent] = useState(null); const [stage, setStage] = useState(""); const [name, setName] = useState(null); const [assignedAgent, setAssignedAgent] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [agentInfo, setAgentInfo] = useState(null); const [agentLog, setAgentLog] = useState([]); const [agentStatus, setAgentStatus] = useState(null); const [testResults, setTestResults] = useState( null, ); const [tokenCost, setTokenCost] = useState(null); const [agentConfig, setAgentConfig] = useState([]); const [assigning, setAssigning] = useState(false); const [assignError, setAssignError] = useState(null); const panelRef = useRef(null); const cleanupRef = useRef<(() => void) | null>(null); useEffect(() => { setLoading(true); setError(null); api .getWorkItemContent(storyId) .then((data) => { setContent(data.content); setStage(data.stage); setName(data.name); setAssignedAgent(data.agent); }) .catch((err: unknown) => { setError(err instanceof Error ? err.message : "Failed to load content"); }) .finally(() => { setLoading(false); }); }, [storyId]); // Fetch test results on mount and when pipeline updates arrive. useEffect(() => { api .getTestResults(storyId) .then((data) => { setTestResults(data); }) .catch(() => { // Silently ignore — test results may not exist yet. }); }, [storyId, pipelineVersion]); // Fetch token cost on mount and when pipeline updates arrive. useEffect(() => { api .getTokenCost(storyId) .then((data) => { setTokenCost(data); }) .catch(() => { // Silently ignore — token cost may not exist yet. }); }, [storyId, pipelineVersion]); useEffect(() => { cleanupRef.current?.(); cleanupRef.current = null; setAgentInfo(null); setAgentLog([]); setAgentStatus(null); agentsApi .listAgents() .then((agents) => { const agent = agents.find((a) => a.story_id === storyId); if (!agent) return; setAgentInfo(agent); setAgentStatus(agent.status); if (agent.status === "running" || agent.status === "pending") { const cleanup = subscribeAgentStream( storyId, agent.agent_name, (event: AgentEvent) => { switch (event.type) { case "status": setAgentStatus((event.status as AgentStatusValue) ?? null); break; case "output": setAgentLog((prev) => [...prev, event.text ?? ""]); break; case "done": setAgentStatus("completed"); break; case "error": setAgentStatus("failed"); setAgentLog((prev) => [ ...prev, `[ERROR] ${event.message ?? "Unknown error"}`, ]); break; default: break; } }, ); cleanupRef.current = cleanup; } }) .catch((err: unknown) => { console.error("Failed to load agents:", err); }); return () => { cleanupRef.current?.(); cleanupRef.current = null; }; }, [storyId]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [onClose]); // Load agent config roster for the dropdown. useEffect(() => { agentsApi .getAgentConfig() .then((config) => { setAgentConfig(config); }) .catch((err: unknown) => { console.error("Failed to load agent config:", err); }); }, []); // Map pipeline stage → agent stage filter. const STAGE_TO_AGENT_STAGE: Record = { current: "coder", qa: "qa", merge: "mergemaster", }; const filteredAgents = agentConfig.filter( (a) => a.stage === STAGE_TO_AGENT_STAGE[stage], ); // The currently active agent name for this story (running or pending). const activeAgentName = agentInfo && (agentStatus === "running" || agentStatus === "pending") ? agentInfo.agent_name : null; const handleAgentAssign = useCallback( async (selectedAgentName: string) => { setAssigning(true); setAssignError(null); try { // Stop current running agent if there is one. if (activeAgentName) { await agentsApi.stopAgent(storyId, activeAgentName); } // Start the new agent (or skip if "none" selected). if (selectedAgentName) { await agentsApi.startAgent(storyId, selectedAgentName); } } catch (err: unknown) { setAssignError( err instanceof Error ? err.message : "Failed to assign agent", ); } finally { setAssigning(false); } }, [storyId, activeAgentName], ); const stageLabel = STAGE_LABELS[stage] ?? stage; const hasTestResults = testResults && (testResults.unit.length > 0 || testResults.integration.length > 0); return (
{/* Header */}
{name ?? storyId}
{stage && (
{stageLabel}
)} {filteredAgents.length > 0 && (
Agent: {assigning && ( Assigning… )} {assignError && ( {assignError} )}
)} {filteredAgents.length === 0 && assignedAgent ? (
Agent: {assignedAgent}
) : null}
{/* Scrollable content area */}
{loading && (
Loading...
)} {error && (
{error}
)} {!loading && !error && content !== null && (
(

{children}

), // biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props h2: ({ children }: any) => (

{children}

), // biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props h3: ({ children }: any) => (

{children}

), }} > {content}
)} {/* Token Cost section */}
Token Cost
{tokenCost && tokenCost.agents.length > 0 ? (
Total:{" "} ${tokenCost.total_cost_usd.toFixed(6)}
{tokenCost.agents.map((agent: AgentCostEntry) => (
{agent.agent_name} {agent.model ? ( {` (${agent.model})`} ) : null} ${agent.total_cost_usd.toFixed(6)}
in {agent.input_tokens.toLocaleString()} / out{" "} {agent.output_tokens.toLocaleString()} {(agent.cache_creation_input_tokens > 0 || agent.cache_read_input_tokens > 0) && ( <> {" "} / cache + {agent.cache_creation_input_tokens.toLocaleString()}{" "} read {agent.cache_read_input_tokens.toLocaleString()} )}
))}
) : (
No token data recorded
)}
{/* Test Results section */}
Test Results
{hasTestResults ? (
) : (
No test results recorded
)}
{/* Agent Logs section */} {!agentInfo && (
Agent Logs
Coming soon
)} {agentInfo && (
Agent Logs
{agentStatus && (
{agentInfo.agent_name} — {agentStatus}
)}
{agentLog.length > 0 ? (
{agentLog.join("")}
) : (
{agentStatus === "running" || agentStatus === "pending" ? "Waiting for output..." : "No output."}
)}
)} {/* Placeholder sections for future content */} {( [{ id: "coverage", label: "Coverage" }] as { id: string; label: string; }[] ).map(({ id, label }) => (
{label}
Coming soon
))}
); }