story-kit: merge 339_story_web_ui_agent_assignment_dropdown_on_work_items

This commit is contained in:
Dave
2026-03-20 09:05:28 +00:00
parent 134cae216a
commit 81e822642e
6 changed files with 140 additions and 3 deletions

View File

@@ -36,6 +36,7 @@ const sampleAgent: AgentInfo = {
const sampleConfig: AgentConfigInfo = {
name: "coder",
role: "engineer",
stage: "coder",
model: "claude-sonnet-4-6",
allowed_tools: null,
max_turns: null,

View File

@@ -31,6 +31,7 @@ export interface AgentEvent {
export interface AgentConfigInfo {
name: string;
role: string;
stage: string | null;
model: string | null;
allowed_tools: string[] | null;
max_turns: number | null;

View File

@@ -29,6 +29,7 @@ const ROSTER: AgentConfigInfo[] = [
{
name: "coder-1",
role: "Full-stack engineer",
stage: "coder",
model: "sonnet",
allowed_tools: null,
max_turns: 50,

View File

@@ -20,6 +20,9 @@ vi.mock("../api/client", async () => {
vi.mock("../api/agents", () => ({
agentsApi: {
listAgents: vi.fn(),
getAgentConfig: vi.fn(),
stopAgent: vi.fn(),
startAgent: vi.fn(),
},
subscribeAgentStream: vi.fn(() => () => {}),
}));
@@ -33,6 +36,7 @@ const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
const mockedGetTestResults = vi.mocked(api.getTestResults);
const mockedGetTokenCost = vi.mocked(api.getTokenCost);
const mockedListAgents = vi.mocked(agentsApi.listAgents);
const mockedGetAgentConfig = vi.mocked(agentsApi.getAgentConfig);
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
const DEFAULT_CONTENT = {
@@ -56,6 +60,7 @@ beforeEach(() => {
mockedGetTestResults.mockResolvedValue(null);
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
mockedListAgents.mockResolvedValue([]);
mockedGetAgentConfig.mockResolvedValue([]);
mockedSubscribeAgentStream.mockReturnValue(() => {});
});

View File

@@ -1,6 +1,11 @@
import * as React from "react";
import Markdown from "react-markdown";
import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents";
import type {
AgentConfigInfo,
AgentEvent,
AgentInfo,
AgentStatusValue,
} from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents";
import type {
AgentCostEntry,
@@ -10,7 +15,7 @@ import type {
} from "../api/client";
import { api } from "../api/client";
const { useEffect, useRef, useState } = React;
const { useCallback, useEffect, useRef, useState } = React;
const STAGE_LABELS: Record<string, string> = {
backlog: "Backlog",
@@ -131,6 +136,9 @@ export function WorkItemDetailPanel({
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);
@@ -242,6 +250,59 @@ export function WorkItemDetailPanel({
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<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).
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 &&
@@ -301,7 +362,72 @@ export function WorkItemDetailPanel({
{stageLabel}
</div>
)}
{assignedAgent ? (
{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" }}

View File

@@ -37,6 +37,7 @@ struct AgentInfoResponse {
struct AgentConfigInfoResponse {
name: String,
role: String,
stage: Option<String>,
model: Option<String>,
allowed_tools: Option<Vec<String>>,
max_turns: Option<u32>,
@@ -275,6 +276,7 @@ impl AgentsApi {
.map(|a| AgentConfigInfoResponse {
name: a.name.clone(),
role: a.role.clone(),
stage: a.stage.clone(),
model: a.model.clone(),
allowed_tools: a.allowed_tools.clone(),
max_turns: a.max_turns,
@@ -304,6 +306,7 @@ impl AgentsApi {
.map(|a| AgentConfigInfoResponse {
name: a.name.clone(),
role: a.role.clone(),
stage: a.stage.clone(),
model: a.model.clone(),
allowed_tools: a.allowed_tools.clone(),
max_turns: a.max_turns,