From 81e822642e995f355939849fbd94f6de347ae811 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Mar 2026 09:05:28 +0000 Subject: [PATCH] story-kit: merge 339_story_web_ui_agent_assignment_dropdown_on_work_items --- frontend/src/api/agents.test.ts | 1 + frontend/src/api/agents.ts | 1 + frontend/src/components/AgentPanel.test.tsx | 1 + .../components/WorkItemDetailPanel.test.tsx | 5 + .../src/components/WorkItemDetailPanel.tsx | 132 +++++++++++++++++- server/src/http/agents.rs | 3 + 6 files changed, 140 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/agents.test.ts b/frontend/src/api/agents.test.ts index ce9a7c1..ca86f6a 100644 --- a/frontend/src/api/agents.test.ts +++ b/frontend/src/api/agents.test.ts @@ -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, diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index 815d342..ca1a139 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -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; diff --git a/frontend/src/components/AgentPanel.test.tsx b/frontend/src/components/AgentPanel.test.tsx index 29b94ac..1931c9f 100644 --- a/frontend/src/components/AgentPanel.test.tsx +++ b/frontend/src/components/AgentPanel.test.tsx @@ -29,6 +29,7 @@ const ROSTER: AgentConfigInfo[] = [ { name: "coder-1", role: "Full-stack engineer", + stage: "coder", model: "sonnet", allowed_tools: null, max_turns: 50, diff --git a/frontend/src/components/WorkItemDetailPanel.test.tsx b/frontend/src/components/WorkItemDetailPanel.test.tsx index dde835b..56127c3 100644 --- a/frontend/src/components/WorkItemDetailPanel.test.tsx +++ b/frontend/src/components/WorkItemDetailPanel.test.tsx @@ -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(() => {}); }); diff --git a/frontend/src/components/WorkItemDetailPanel.tsx b/frontend/src/components/WorkItemDetailPanel.tsx index 89cc07b..cc837b5 100644 --- a/frontend/src/components/WorkItemDetailPanel.tsx +++ b/frontend/src/components/WorkItemDetailPanel.tsx @@ -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 = { backlog: "Backlog", @@ -131,6 +136,9 @@ export function WorkItemDetailPanel({ null, ); const [tokenCost, setTokenCost] = useState(null); + const [agentConfig, setAgentConfig] = useState([]); + const [assigning, setAssigning] = useState(false); + const [assignError, setAssignError] = useState(null); const panelRef = useRef(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 = { + 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} )} - {assignedAgent ? ( + {filteredAgents.length > 0 && ( +
+ Agent: + + {assigning && ( + + Assigning… + + )} + {assignError && ( + + {assignError} + + )} +
+ )} + {filteredAgents.length === 0 && assignedAgent ? (
, model: Option, allowed_tools: Option>, max_turns: Option, @@ -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,