huskies: merge 715_refactor_decompose_frontend_src_components_workitemdetailpanel_tsx_827_lines
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user