Files
huskies/frontend/src/components/WorkItemDetailPanel.tsx
T

342 lines
8.7 KiB
TypeScript

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 {
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;
interface WorkItemDetailPanelProps {
storyId: string;
pipelineVersion: number;
onClose: () => void;
/** True when the item is in QA and awaiting human review. */
reviewHold?: boolean;
}
export function WorkItemDetailPanel({
storyId,
pipelineVersion,
onClose,
reviewHold: _reviewHold,
}: WorkItemDetailPanelProps) {
const [content, setContent] = useState<string | null>(null);
const [stage, setStage] = useState<string>("");
const [name, setName] = useState<string | null>(null);
const [assignedAgent, setAssignedAgent] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
const [agentLog, setAgentLog] = useState<string[]>([]);
const [agentStatus, setAgentStatus] = useState<AgentStatusValue | null>(null);
const [testResults, setTestResults] = useState<TestResultsResponse | null>(
null,
);
const [tokenCost, setTokenCost] = useState<TokenCostResponse | null>(null);
const [agentConfig, setAgentConfig] = useState<AgentConfigInfo[]>([]);
const [assigning, setAssigning] = useState(false);
const [assignError, setAssignError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(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);
});
}, []);
// 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],
);
return (
<div
data-testid="work-item-detail-panel"
ref={panelRef}
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
background: "#1a1a1a",
borderRadius: "8px",
border: "1px solid #333",
}}
>
<WorkItemDetailPanelHeader
storyId={storyId}
name={name}
stage={stage}
assignedAgent={assignedAgent}
agentConfig={agentConfig}
agentInfo={agentInfo}
agentStatus={agentStatus}
assigning={assigning}
assignError={assignError}
onAgentAssign={handleAgentAssign}
onClose={onClose}
/>
{/* Scrollable content area */}
<div
style={{
flex: 1,
overflowY: "auto",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "16px",
}}
>
{loading && (
<div
data-testid="detail-panel-loading"
style={{ color: "#666", fontSize: "0.85em" }}
>
Loading...
</div>
)}
{error && (
<div
data-testid="detail-panel-error"
style={{ color: "#ff7b72", fontSize: "0.85em" }}
>
{error}
</div>
)}
{!loading && !error && content !== null && (
<div
data-testid="detail-panel-content"
className="markdown-body"
style={{ fontSize: "0.9em", lineHeight: 1.6 }}
>
<Markdown
components={{
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
h1: ({ children }: any) => (
<h1 style={{ fontSize: "1.2em" }}>{children}</h1>
),
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
h2: ({ children }: any) => (
<h2 style={{ fontSize: "1.1em" }}>{children}</h2>
),
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
h3: ({ children }: any) => (
<h3 style={{ fontSize: "1em" }}>{children}</h3>
),
}}
>
{stripDisplayContent(content)}
</Markdown>
</div>
)}
<TokenCostSection tokenCost={tokenCost} />
<TestResultsSection testResults={testResults} />
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<AgentLogsSection
agentInfo={agentInfo}
agentStatus={agentStatus}
agentLog={agentLog}
/>
{/* Placeholder sections for future content */}
{(
[{ id: "coverage", label: "Coverage" }] as {
id: string;
label: string;
}[]
).map(({ id, label }) => (
<div
key={id}
data-testid={`placeholder-${id}`}
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "4px",
}}
>
{label}
</div>
<div style={{ fontSize: "0.75em", color: "#444" }}>
Coming soon
</div>
</div>
))}
</div>
</div>
</div>
);
}