huskies: merge 715_refactor_decompose_frontend_src_components_workitemdetailpanel_tsx_827_lines

This commit is contained in:
dave
2026-04-27 17:03:43 +00:00
parent fce7e16811
commit 77081926d1
6 changed files with 622 additions and 511 deletions
@@ -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 (
<div
data-testid="placeholder-agent-logs"
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "4px",
}}
>
Agent Logs
</div>
<div style={{ fontSize: "0.75em", color: "#444" }}>Coming soon</div>
</div>
);
}
return (
<div
data-testid="agent-logs-section"
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "6px",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#888",
}}
>
Agent Logs
</div>
{agentStatus && (
<div
data-testid="agent-status-badge"
style={{
fontSize: "0.7em",
color: STATUS_COLORS[agentStatus],
fontWeight: 600,
}}
>
{agentInfo.agent_name} {agentStatus}
</div>
)}
</div>
{agentLog.length > 0 ? (
<div
data-testid="agent-log-output"
style={{
fontSize: "0.75em",
fontFamily: "monospace",
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
lineHeight: "1.5",
maxHeight: "200px",
overflowY: "auto",
}}
>
{agentLog.join("")}
</div>
) : (
<div style={{ fontSize: "0.75em", color: "#444" }}>
{agentStatus === "running" || agentStatus === "pending"
? "Waiting for output..."
: "No output."}
</div>
)}
</div>
);
}
@@ -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 (
<div
data-testid={`test-case-${tc.name}`}
style={{
display: "flex",
flexDirection: "column",
gap: "2px",
padding: "4px 0",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span
data-testid={`test-status-${tc.name}`}
style={{
fontSize: "0.85em",
color: isPassing ? "#3fb950" : "#f85149",
}}
>
{isPassing ? "PASS" : "FAIL"}
</span>
<span style={{ fontSize: "0.82em", color: "#ccc" }}>{tc.name}</span>
</div>
{tc.details && (
<div
data-testid={`test-details-${tc.name}`}
style={{
fontSize: "0.75em",
color: "#888",
paddingLeft: "22px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{tc.details}
</div>
)}
</div>
);
}
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 (
<div data-testid={testId}>
<div
style={{
fontSize: "0.78em",
fontWeight: 600,
color: "#aaa",
marginBottom: "6px",
}}
>
{title} ({passCount} passed, {failCount} failed)
</div>
{tests.length === 0 ? (
<div style={{ fontSize: "0.75em", color: "#555", fontStyle: "italic" }}>
No tests recorded
</div>
) : (
tests.map((tc) => <TestCaseRow key={tc.name} tc={tc} />)
)}
</div>
);
}
/** 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 (
<div
data-testid="test-results-section"
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "8px",
}}
>
Test Results
</div>
{hasTestResults ? (
<div
data-testid="test-results-content"
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<TestSection
title="Unit Tests"
tests={testResults.unit}
testId="test-section-unit"
/>
<TestSection
title="Integration Tests"
tests={testResults.integration}
testId="test-section-integration"
/>
</div>
) : (
<div
data-testid="test-results-empty"
style={{ fontSize: "0.75em", color: "#444" }}
>
No test results recorded
</div>
)}
</div>
);
}
@@ -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 (
<div
data-testid="token-cost-section"
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "8px",
}}
>
Token Cost
</div>
{tokenCost && tokenCost.agents.length > 0 ? (
<div data-testid="token-cost-content">
<div
style={{
fontSize: "0.75em",
color: "#888",
marginBottom: "8px",
}}
>
Total:{" "}
<span data-testid="token-cost-total" style={{ color: "#ccc" }}>
${tokenCost.total_cost_usd.toFixed(6)}
</span>
</div>
{tokenCost.agents.map((agent: AgentCostEntry) => (
<div
key={agent.agent_name}
data-testid={`token-cost-agent-${agent.agent_name}`}
style={{
fontSize: "0.75em",
color: "#888",
padding: "4px 0",
borderTop: "1px solid #222",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "2px",
}}
>
<span style={{ color: "#ccc", fontWeight: 600 }}>
{agent.agent_name}
{agent.model ? (
<span
style={{ color: "#666", fontWeight: 400 }}
>{` (${agent.model})`}</span>
) : null}
</span>
<span style={{ color: "#aaa" }}>
${agent.total_cost_usd.toFixed(6)}
</span>
</div>
<div style={{ color: "#555" }}>
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()}
</>
)}
</div>
</div>
))}
</div>
) : (
<div
data-testid="token-cost-empty"
style={{ fontSize: "0.75em", color: "#444" }}
>
No token data recorded
</div>
)}
</div>
);
}
+25 -511
View File
@@ -8,71 +8,18 @@ import type {
} from "../api/agents"; } from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents";
import type { import type {
AgentCostEntry,
TestCaseResult,
TestResultsResponse, TestResultsResponse,
TokenCostResponse, TokenCostResponse,
} from "../api/client"; } from "../api/client";
import { api } 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; 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<string, string> = {
backlog: "Backlog",
current: "Current",
qa: "QA",
merge: "To Merge",
done: "Done",
archived: "Archived",
};
const STATUS_COLORS: Record<AgentStatusValue, string> = {
running: "#3fb950",
pending: "#e3b341",
completed: "#aaa",
failed: "#f85149",
};
interface WorkItemDetailPanelProps { interface WorkItemDetailPanelProps {
storyId: string; storyId: string;
pipelineVersion: number; pipelineVersion: number;
@@ -81,82 +28,6 @@ interface WorkItemDetailPanelProps {
reviewHold?: boolean; reviewHold?: boolean;
} }
function TestCaseRow({ tc }: { tc: TestCaseResult }) {
const isPassing = tc.status === "pass";
return (
<div
data-testid={`test-case-${tc.name}`}
style={{
display: "flex",
flexDirection: "column",
gap: "2px",
padding: "4px 0",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span
data-testid={`test-status-${tc.name}`}
style={{
fontSize: "0.85em",
color: isPassing ? "#3fb950" : "#f85149",
}}
>
{isPassing ? "PASS" : "FAIL"}
</span>
<span style={{ fontSize: "0.82em", color: "#ccc" }}>{tc.name}</span>
</div>
{tc.details && (
<div
data-testid={`test-details-${tc.name}`}
style={{
fontSize: "0.75em",
color: "#888",
paddingLeft: "22px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{tc.details}
</div>
)}
</div>
);
}
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 (
<div data-testid={testId}>
<div
style={{
fontSize: "0.78em",
fontWeight: 600,
color: "#aaa",
marginBottom: "6px",
}}
>
{title} ({passCount} passed, {failCount} failed)
</div>
{tests.length === 0 ? (
<div style={{ fontSize: "0.75em", color: "#555", fontStyle: "italic" }}>
No tests recorded
</div>
) : (
tests.map((tc) => <TestCaseRow key={tc.name} tc={tc} />)
)}
</div>
);
}
export function WorkItemDetailPanel({ export function WorkItemDetailPanel({
storyId, storyId,
pipelineVersion, pipelineVersion,
@@ -302,17 +173,6 @@ export function WorkItemDetailPanel({
}); });
}, []); }, []);
// Map pipeline stage → agent stage filter.
const STAGE_TO_AGENT_STAGE: Record<string, string> = {
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). // The currently active agent name for this story (running or pending).
const activeAgentName = const activeAgentName =
agentInfo && (agentStatus === "running" || agentStatus === "pending") agentInfo && (agentStatus === "running" || agentStatus === "pending")
@@ -343,11 +203,6 @@ export function WorkItemDetailPanel({
[storyId, activeAgentName], [storyId, activeAgentName],
); );
const stageLabel = STAGE_LABELS[stage] ?? stage;
const hasTestResults =
testResults &&
(testResults.unit.length > 0 || testResults.integration.length > 0);
return ( return (
<div <div
data-testid="work-item-detail-panel" data-testid="work-item-detail-panel"
@@ -362,138 +217,19 @@ export function WorkItemDetailPanel({
border: "1px solid #333", border: "1px solid #333",
}} }}
> >
{/* Header */} <WorkItemDetailPanelHeader
<div storyId={storyId}
style={{ name={name}
display: "flex", stage={stage}
alignItems: "center", assignedAgent={assignedAgent}
justifyContent: "space-between", agentConfig={agentConfig}
padding: "12px 16px", agentInfo={agentInfo}
borderBottom: "1px solid #333", agentStatus={agentStatus}
flexShrink: 0, assigning={assigning}
}} assignError={assignError}
> onAgentAssign={handleAgentAssign}
<div onClose={onClose}
style={{ />
display: "flex",
flexDirection: "column",
gap: "2px",
minWidth: 0,
}}
>
<div
data-testid="detail-panel-title"
style={{
fontWeight: 600,
fontSize: "0.95em",
color: "#ececec",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{formatStoryTitle(storyId, name)}
</div>
{stage && (
<div
data-testid="detail-panel-stage"
style={{ fontSize: "0.75em", color: "#888" }}
>
{stageLabel}
</div>
)}
{filteredAgents.length > 0 && (
<div
data-testid="detail-panel-agent-assignment"
style={{
display: "flex",
alignItems: "center",
gap: "6px",
marginTop: "4px",
}}
>
<span style={{ fontSize: "0.75em", color: "#666" }}>Agent:</span>
<select
data-testid="agent-assignment-dropdown"
disabled={assigning}
value={activeAgentName ?? assignedAgent ?? ""}
onChange={(e) => handleAgentAssign(e.target.value)}
style={{
background: "#1a1a1a",
border: "1px solid #444",
borderRadius: "4px",
color: "#ccc",
cursor: assigning ? "not-allowed" : "pointer",
fontSize: "0.75em",
padding: "2px 6px",
opacity: assigning ? 0.6 : 1,
}}
>
<option value=""> none </option>
{filteredAgents.map((a) => {
const isRunning =
agentInfo?.agent_name === a.name &&
agentStatus === "running";
const isPending =
agentInfo?.agent_name === a.name &&
agentStatus === "pending";
const statusLabel = isRunning
? " — running"
: isPending
? " — pending"
: " — idle";
const modelPart = a.model ? ` (${a.model})` : "";
return (
<option key={a.name} value={a.name}>
{a.name}
{modelPart}
{statusLabel}
</option>
);
})}
</select>
{assigning && (
<span style={{ fontSize: "0.7em", color: "#888" }}>
Assigning
</span>
)}
{assignError && (
<span
data-testid="agent-assignment-error"
style={{ fontSize: "0.7em", color: "#f85149" }}
>
{assignError}
</span>
)}
</div>
)}
{filteredAgents.length === 0 && assignedAgent ? (
<div
data-testid="detail-panel-assigned-agent"
style={{ fontSize: "0.75em", color: "#888" }}
>
Agent: {assignedAgent}
</div>
) : null}
</div>
<button
type="button"
data-testid="detail-panel-close"
onClick={onClose}
style={{
background: "none",
border: "1px solid #444",
borderRadius: "6px",
color: "#aaa",
cursor: "pointer",
padding: "4px 10px",
fontSize: "0.8em",
flexShrink: 0,
}}
>
Close
</button>
</div>
{/* Scrollable content area */} {/* Scrollable content area */}
<div <div
@@ -549,145 +285,9 @@ export function WorkItemDetailPanel({
</div> </div>
)} )}
{/* Token Cost section */} <TokenCostSection tokenCost={tokenCost} />
<div
data-testid="token-cost-section"
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "8px",
}}
>
Token Cost
</div>
{tokenCost && tokenCost.agents.length > 0 ? (
<div data-testid="token-cost-content">
<div
style={{
fontSize: "0.75em",
color: "#888",
marginBottom: "8px",
}}
>
Total:{" "}
<span data-testid="token-cost-total" style={{ color: "#ccc" }}>
${tokenCost.total_cost_usd.toFixed(6)}
</span>
</div>
{tokenCost.agents.map((agent: AgentCostEntry) => (
<div
key={agent.agent_name}
data-testid={`token-cost-agent-${agent.agent_name}`}
style={{
fontSize: "0.75em",
color: "#888",
padding: "4px 0",
borderTop: "1px solid #222",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "2px",
}}
>
<span style={{ color: "#ccc", fontWeight: 600 }}>
{agent.agent_name}
{agent.model ? (
<span
style={{ color: "#666", fontWeight: 400 }}
>{` (${agent.model})`}</span>
) : null}
</span>
<span style={{ color: "#aaa" }}>
${agent.total_cost_usd.toFixed(6)}
</span>
</div>
<div style={{ color: "#555" }}>
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()}
</>
)}
</div>
</div>
))}
</div>
) : (
<div
data-testid="token-cost-empty"
style={{ fontSize: "0.75em", color: "#444" }}
>
No token data recorded
</div>
)}
</div>
{/* Test Results section */} <TestResultsSection testResults={testResults} />
<div
data-testid="test-results-section"
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "8px",
}}
>
Test Results
</div>
{hasTestResults ? (
<div
data-testid="test-results-content"
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<TestSection
title="Unit Tests"
tests={testResults.unit}
testId="test-section-unit"
/>
<TestSection
title="Integration Tests"
tests={testResults.integration}
testId="test-section-integration"
/>
</div>
) : (
<div
data-testid="test-results-empty"
style={{ fontSize: "0.75em", color: "#444" }}
>
No test results recorded
</div>
)}
</div>
<div <div
style={{ style={{
@@ -696,97 +296,11 @@ export function WorkItemDetailPanel({
gap: "8px", gap: "8px",
}} }}
> >
{/* Agent Logs section */} <AgentLogsSection
{!agentInfo && ( agentInfo={agentInfo}
<div agentStatus={agentStatus}
data-testid="placeholder-agent-logs" agentLog={agentLog}
style={{ />
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "4px",
}}
>
Agent Logs
</div>
<div style={{ fontSize: "0.75em", color: "#444" }}>
Coming soon
</div>
</div>
)}
{agentInfo && (
<div
data-testid="agent-logs-section"
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "6px",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#888",
}}
>
Agent Logs
</div>
{agentStatus && (
<div
data-testid="agent-status-badge"
style={{
fontSize: "0.7em",
color: STATUS_COLORS[agentStatus],
fontWeight: 600,
}}
>
{agentInfo.agent_name} {agentStatus}
</div>
)}
</div>
{agentLog.length > 0 ? (
<div
data-testid="agent-log-output"
style={{
fontSize: "0.75em",
fontFamily: "monospace",
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
lineHeight: "1.5",
maxHeight: "200px",
overflowY: "auto",
}}
>
{agentLog.join("")}
</div>
) : (
<div style={{ fontSize: "0.75em", color: "#444" }}>
{agentStatus === "running" || agentStatus === "pending"
? "Waiting for output..."
: "No output."}
</div>
)}
</div>
)}
{/* Placeholder sections for future content */} {/* Placeholder sections for future content */}
{( {(
@@ -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<string, string> = {
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<void>;
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 (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
borderBottom: "1px solid #333",
flexShrink: 0,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "2px",
minWidth: 0,
}}
>
<div
data-testid="detail-panel-title"
style={{
fontWeight: 600,
fontSize: "0.95em",
color: "#ececec",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{formatStoryTitle(storyId, name)}
</div>
{stage && (
<div
data-testid="detail-panel-stage"
style={{ fontSize: "0.75em", color: "#888" }}
>
{stageLabel}
</div>
)}
{filteredAgents.length > 0 && (
<div
data-testid="detail-panel-agent-assignment"
style={{
display: "flex",
alignItems: "center",
gap: "6px",
marginTop: "4px",
}}
>
<span style={{ fontSize: "0.75em", color: "#666" }}>Agent:</span>
<select
data-testid="agent-assignment-dropdown"
disabled={assigning}
value={activeAgentName ?? assignedAgent ?? ""}
onChange={(e) => onAgentAssign(e.target.value)}
style={{
background: "#1a1a1a",
border: "1px solid #444",
borderRadius: "4px",
color: "#ccc",
cursor: assigning ? "not-allowed" : "pointer",
fontSize: "0.75em",
padding: "2px 6px",
opacity: assigning ? 0.6 : 1,
}}
>
<option value=""> none </option>
{filteredAgents.map((a) => {
const isRunning =
agentInfo?.agent_name === a.name &&
agentStatus === "running";
const isPending =
agentInfo?.agent_name === a.name &&
agentStatus === "pending";
const statusLabel = isRunning
? " — running"
: isPending
? " — pending"
: " — idle";
const modelPart = a.model ? ` (${a.model})` : "";
return (
<option key={a.name} value={a.name}>
{a.name}
{modelPart}
{statusLabel}
</option>
);
})}
</select>
{assigning && (
<span style={{ fontSize: "0.7em", color: "#888" }}>
Assigning
</span>
)}
{assignError && (
<span
data-testid="agent-assignment-error"
style={{ fontSize: "0.7em", color: "#f85149" }}
>
{assignError}
</span>
)}
</div>
)}
{filteredAgents.length === 0 && assignedAgent ? (
<div
data-testid="detail-panel-assigned-agent"
style={{ fontSize: "0.75em", color: "#888" }}
>
Agent: {assignedAgent}
</div>
) : null}
</div>
<button
type="button"
data-testid="detail-panel-close"
onClick={onClose}
style={{
background: "none",
border: "1px solid #444",
borderRadius: "6px",
color: "#aaa",
cursor: "pointer",
padding: "4px 10px",
fontSize: "0.8em",
flexShrink: 0,
}}
>
Close
</button>
</div>
);
}
@@ -0,0 +1,59 @@
/** Shared utility functions and constants for WorkItemDetailPanel sub-components. */
import type { AgentStatusValue } from "../api/agents";
export const STAGE_LABELS: Record<string, string> = {
backlog: "Backlog",
current: "Current",
qa: "QA",
merge: "To Merge",
done: "Done",
archived: "Archived",
};
export const STATUS_COLORS: Record<AgentStatusValue, string> = {
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}`;
}