diff --git a/frontend/src/components/AgentLogsSection.tsx b/frontend/src/components/AgentLogsSection.tsx new file mode 100644 index 00000000..ccb56bdf --- /dev/null +++ b/frontend/src/components/AgentLogsSection.tsx @@ -0,0 +1,112 @@ +/** Agent logs card sub-component for WorkItemDetailPanel. */ + +import type { AgentInfo, AgentStatusValue } from "../api/agents"; +import { STATUS_COLORS } from "./workItemDetailPanelUtils"; + +interface AgentLogsSectionProps { + agentInfo: AgentInfo | null; + agentStatus: AgentStatusValue | null; + agentLog: string[]; +} + +/** + * Renders the "Agent Logs" card when an agent is active, or a placeholder + * when no agent is assigned to the story. + */ +export function AgentLogsSection({ + agentInfo, + agentStatus, + agentLog, +}: AgentLogsSectionProps) { + if (!agentInfo) { + return ( +
+
+ Agent Logs +
+
Coming soon
+
+ ); + } + + return ( +
+
+
+ Agent Logs +
+ {agentStatus && ( +
+ {agentInfo.agent_name} — {agentStatus} +
+ )} +
+ {agentLog.length > 0 ? ( +
+ {agentLog.join("")} +
+ ) : ( +
+ {agentStatus === "running" || agentStatus === "pending" + ? "Waiting for output..." + : "No output."} +
+ )} +
+ ); +} diff --git a/frontend/src/components/TestResultsSection.tsx b/frontend/src/components/TestResultsSection.tsx new file mode 100644 index 00000000..e03fa3c4 --- /dev/null +++ b/frontend/src/components/TestResultsSection.tsx @@ -0,0 +1,141 @@ +/** Test results card sub-components for WorkItemDetailPanel. */ + +import type { TestCaseResult, TestResultsResponse } from "../api/client"; + +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) => ) + )} +
+ ); +} + +/** Renders the "Test Results" card in the detail panel. */ +export function TestResultsSection({ + testResults, +}: { + testResults: TestResultsResponse | null; +}) { + const hasTestResults = + testResults && + (testResults.unit.length > 0 || testResults.integration.length > 0); + + return ( +
+
+ Test Results +
+ {hasTestResults ? ( +
+ + +
+ ) : ( +
+ No test results recorded +
+ )} +
+ ); +} diff --git a/frontend/src/components/TokenCostSection.tsx b/frontend/src/components/TokenCostSection.tsx new file mode 100644 index 00000000..af0070e0 --- /dev/null +++ b/frontend/src/components/TokenCostSection.tsx @@ -0,0 +1,101 @@ +/** Token cost card sub-component for WorkItemDetailPanel. */ + +import type { AgentCostEntry, TokenCostResponse } from "../api/client"; + +/** Renders the "Token Cost" card in the detail panel. */ +export function TokenCostSection({ + tokenCost, +}: { + tokenCost: TokenCostResponse | null; +}) { + return ( +
+
+ 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 +
+ )} +
+ ); +} diff --git a/frontend/src/components/WorkItemDetailPanel.tsx b/frontend/src/components/WorkItemDetailPanel.tsx index f119c923..cdf58e23 100644 --- a/frontend/src/components/WorkItemDetailPanel.tsx +++ b/frontend/src/components/WorkItemDetailPanel.tsx @@ -8,71 +8,18 @@ import type { } from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents"; import type { - AgentCostEntry, - TestCaseResult, TestResultsResponse, TokenCostResponse, } from "../api/client"; import { api } from "../api/client"; +import { AgentLogsSection } from "./AgentLogsSection"; +import { TestResultsSection } from "./TestResultsSection"; +import { TokenCostSection } from "./TokenCostSection"; +import { WorkItemDetailPanelHeader } from "./WorkItemDetailPanelHeader"; +import { stripDisplayContent } from "./workItemDetailPanelUtils"; const { useCallback, useEffect, useRef, useState } = React; -/** - * Strip YAML front matter and the first H1 heading from story content before - * rendering. The panel header already shows the story ID/title, so rendering - * them again inside the markdown body creates duplicate information. - */ -function stripDisplayContent(content: string): string { - let text = content; - // Strip YAML front matter (--- ... ---) - if (text.startsWith("---")) { - const eol = text.indexOf("\n"); - if (eol !== -1) { - const closeIdx = text.indexOf("\n---", eol); - if (closeIdx !== -1) { - text = text.slice(closeIdx + 4); - } - } - } - // Trim leading blank lines left by the front matter - text = text.trimStart(); - // Strip the first H1 heading — it duplicates the panel header title - if (text.startsWith("# ")) { - const eol = text.indexOf("\n"); - text = eol !== -1 ? text.slice(eol + 1).trimStart() : ""; - } - return text; -} - -/** - * Format the story ID/title line shown in the panel header. - * Produces e.g. "Story 454: My Story Name" or "Bug 12: Crash on startup". - * Falls back to name or storyId when the pattern doesn't match. - */ -function formatStoryTitle(storyId: string, name: string | null): string { - const match = storyId.match(/^(\d+)_([a-z]+)_/); - if (!match || !name) return name ?? storyId; - const [, number, type] = match; - const typeLabel = type.charAt(0).toUpperCase() + type.slice(1); - return `${typeLabel} ${number}: ${name}`; -} - -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; @@ -81,82 +28,6 @@ interface WorkItemDetailPanelProps { 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, @@ -302,17 +173,6 @@ export function WorkItemDetailPanel({ }); }, []); - // 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") @@ -343,11 +203,6 @@ export function WorkItemDetailPanel({ [storyId, activeAgentName], ); - const stageLabel = STAGE_LABELS[stage] ?? stage; - const hasTestResults = - testResults && - (testResults.unit.length > 0 || testResults.integration.length > 0); - return (
- {/* Header */} -
-
-
- {formatStoryTitle(storyId, name)} -
- {stage && ( -
- {stageLabel} -
- )} - {filteredAgents.length > 0 && ( -
- Agent: - - {assigning && ( - - Assigning… - - )} - {assignError && ( - - {assignError} - - )} -
- )} - {filteredAgents.length === 0 && assignedAgent ? ( -
- Agent: {assignedAgent} -
- ) : null} -
- -
+ {/* Scrollable content area */}
)} - {/* 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 */} {( diff --git a/frontend/src/components/WorkItemDetailPanelHeader.tsx b/frontend/src/components/WorkItemDetailPanelHeader.tsx new file mode 100644 index 00000000..78e5efe0 --- /dev/null +++ b/frontend/src/components/WorkItemDetailPanelHeader.tsx @@ -0,0 +1,184 @@ +/** Header sub-component for WorkItemDetailPanel. */ + +import type { AgentConfigInfo, AgentInfo, AgentStatusValue } from "../api/agents"; +import { STAGE_LABELS, formatStoryTitle } from "./workItemDetailPanelUtils"; + +const STAGE_TO_AGENT_STAGE: Record = { + current: "coder", + qa: "qa", + merge: "mergemaster", +}; + +interface WorkItemDetailPanelHeaderProps { + storyId: string; + name: string | null; + stage: string; + assignedAgent: string | null; + agentConfig: AgentConfigInfo[]; + agentInfo: AgentInfo | null; + agentStatus: AgentStatusValue | null; + assigning: boolean; + assignError: string | null; + onAgentAssign: (agentName: string) => Promise; + onClose: () => void; +} + +/** + * Panel header: title, stage label, agent assignment dropdown, and close button. + */ +export function WorkItemDetailPanelHeader({ + storyId, + name, + stage, + assignedAgent, + agentConfig, + agentInfo, + agentStatus, + assigning, + assignError, + onAgentAssign, + onClose, +}: WorkItemDetailPanelHeaderProps) { + const stageLabel = STAGE_LABELS[stage] ?? stage; + const filteredAgents = agentConfig.filter( + (a) => a.stage === STAGE_TO_AGENT_STAGE[stage], + ); + const activeAgentName = + agentInfo && (agentStatus === "running" || agentStatus === "pending") + ? agentInfo.agent_name + : null; + + return ( +
+
+
+ {formatStoryTitle(storyId, name)} +
+ {stage && ( +
+ {stageLabel} +
+ )} + {filteredAgents.length > 0 && ( +
+ Agent: + + {assigning && ( + + Assigning… + + )} + {assignError && ( + + {assignError} + + )} +
+ )} + {filteredAgents.length === 0 && assignedAgent ? ( +
+ Agent: {assignedAgent} +
+ ) : null} +
+ +
+ ); +} diff --git a/frontend/src/components/workItemDetailPanelUtils.ts b/frontend/src/components/workItemDetailPanelUtils.ts new file mode 100644 index 00000000..82f5d425 --- /dev/null +++ b/frontend/src/components/workItemDetailPanelUtils.ts @@ -0,0 +1,59 @@ +/** Shared utility functions and constants for WorkItemDetailPanel sub-components. */ + +import type { AgentStatusValue } from "../api/agents"; + +export const STAGE_LABELS: Record = { + backlog: "Backlog", + current: "Current", + qa: "QA", + merge: "To Merge", + done: "Done", + archived: "Archived", +}; + +export const STATUS_COLORS: Record = { + running: "#3fb950", + pending: "#e3b341", + completed: "#aaa", + failed: "#f85149", +}; + +/** + * Strip YAML front matter and the first H1 heading from story content before + * rendering. The panel header already shows the story ID/title, so rendering + * them again inside the markdown body creates duplicate information. + */ +export function stripDisplayContent(content: string): string { + let text = content; + // Strip YAML front matter (--- ... ---) + if (text.startsWith("---")) { + const eol = text.indexOf("\n"); + if (eol !== -1) { + const closeIdx = text.indexOf("\n---", eol); + if (closeIdx !== -1) { + text = text.slice(closeIdx + 4); + } + } + } + // Trim leading blank lines left by the front matter + text = text.trimStart(); + // Strip the first H1 heading — it duplicates the panel header title + if (text.startsWith("# ")) { + const eol = text.indexOf("\n"); + text = eol !== -1 ? text.slice(eol + 1).trimStart() : ""; + } + return text; +} + +/** + * Format the story ID/title line shown in the panel header. + * Produces e.g. "Story 454: My Story Name" or "Bug 12: Crash on startup". + * Falls back to name or storyId when the pattern doesn't match. + */ +export function formatStoryTitle(storyId: string, name: string | null): string { + const match = storyId.match(/^(\d+)_([a-z]+)_/); + if (!match || !name) return name ?? storyId; + const [, number, type] = match; + const typeLabel = type.charAt(0).toUpperCase() + type.slice(1); + return `${typeLabel} ${number}: ${name}`; +}