story-kit: merge 339_story_web_ui_agent_assignment_dropdown_on_work_items
This commit is contained in:
@@ -36,6 +36,7 @@ const sampleAgent: AgentInfo = {
|
|||||||
const sampleConfig: AgentConfigInfo = {
|
const sampleConfig: AgentConfigInfo = {
|
||||||
name: "coder",
|
name: "coder",
|
||||||
role: "engineer",
|
role: "engineer",
|
||||||
|
stage: "coder",
|
||||||
model: "claude-sonnet-4-6",
|
model: "claude-sonnet-4-6",
|
||||||
allowed_tools: null,
|
allowed_tools: null,
|
||||||
max_turns: null,
|
max_turns: null,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface AgentEvent {
|
|||||||
export interface AgentConfigInfo {
|
export interface AgentConfigInfo {
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
stage: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
allowed_tools: string[] | null;
|
allowed_tools: string[] | null;
|
||||||
max_turns: number | null;
|
max_turns: number | null;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const ROSTER: AgentConfigInfo[] = [
|
|||||||
{
|
{
|
||||||
name: "coder-1",
|
name: "coder-1",
|
||||||
role: "Full-stack engineer",
|
role: "Full-stack engineer",
|
||||||
|
stage: "coder",
|
||||||
model: "sonnet",
|
model: "sonnet",
|
||||||
allowed_tools: null,
|
allowed_tools: null,
|
||||||
max_turns: 50,
|
max_turns: 50,
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ vi.mock("../api/client", async () => {
|
|||||||
vi.mock("../api/agents", () => ({
|
vi.mock("../api/agents", () => ({
|
||||||
agentsApi: {
|
agentsApi: {
|
||||||
listAgents: vi.fn(),
|
listAgents: vi.fn(),
|
||||||
|
getAgentConfig: vi.fn(),
|
||||||
|
stopAgent: vi.fn(),
|
||||||
|
startAgent: vi.fn(),
|
||||||
},
|
},
|
||||||
subscribeAgentStream: vi.fn(() => () => {}),
|
subscribeAgentStream: vi.fn(() => () => {}),
|
||||||
}));
|
}));
|
||||||
@@ -33,6 +36,7 @@ const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
|
|||||||
const mockedGetTestResults = vi.mocked(api.getTestResults);
|
const mockedGetTestResults = vi.mocked(api.getTestResults);
|
||||||
const mockedGetTokenCost = vi.mocked(api.getTokenCost);
|
const mockedGetTokenCost = vi.mocked(api.getTokenCost);
|
||||||
const mockedListAgents = vi.mocked(agentsApi.listAgents);
|
const mockedListAgents = vi.mocked(agentsApi.listAgents);
|
||||||
|
const mockedGetAgentConfig = vi.mocked(agentsApi.getAgentConfig);
|
||||||
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
||||||
|
|
||||||
const DEFAULT_CONTENT = {
|
const DEFAULT_CONTENT = {
|
||||||
@@ -56,6 +60,7 @@ beforeEach(() => {
|
|||||||
mockedGetTestResults.mockResolvedValue(null);
|
mockedGetTestResults.mockResolvedValue(null);
|
||||||
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||||
mockedListAgents.mockResolvedValue([]);
|
mockedListAgents.mockResolvedValue([]);
|
||||||
|
mockedGetAgentConfig.mockResolvedValue([]);
|
||||||
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Markdown from "react-markdown";
|
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 { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||||
import type {
|
import type {
|
||||||
AgentCostEntry,
|
AgentCostEntry,
|
||||||
@@ -10,7 +15,7 @@ import type {
|
|||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import { api } 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> = {
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
backlog: "Backlog",
|
backlog: "Backlog",
|
||||||
@@ -131,6 +136,9 @@ export function WorkItemDetailPanel({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [tokenCost, setTokenCost] = useState<TokenCostResponse | 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 panelRef = useRef<HTMLDivElement>(null);
|
||||||
const cleanupRef = useRef<(() => void) | null>(null);
|
const cleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
@@ -242,6 +250,59 @@ export function WorkItemDetailPanel({
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onClose]);
|
}, [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 stageLabel = STAGE_LABELS[stage] ?? stage;
|
||||||
const hasTestResults =
|
const hasTestResults =
|
||||||
testResults &&
|
testResults &&
|
||||||
@@ -301,7 +362,72 @@ export function WorkItemDetailPanel({
|
|||||||
{stageLabel}
|
{stageLabel}
|
||||||
</div>
|
</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
|
<div
|
||||||
data-testid="detail-panel-assigned-agent"
|
data-testid="detail-panel-assigned-agent"
|
||||||
style={{ fontSize: "0.75em", color: "#888" }}
|
style={{ fontSize: "0.75em", color: "#888" }}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ struct AgentInfoResponse {
|
|||||||
struct AgentConfigInfoResponse {
|
struct AgentConfigInfoResponse {
|
||||||
name: String,
|
name: String,
|
||||||
role: String,
|
role: String,
|
||||||
|
stage: Option<String>,
|
||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
allowed_tools: Option<Vec<String>>,
|
allowed_tools: Option<Vec<String>>,
|
||||||
max_turns: Option<u32>,
|
max_turns: Option<u32>,
|
||||||
@@ -275,6 +276,7 @@ impl AgentsApi {
|
|||||||
.map(|a| AgentConfigInfoResponse {
|
.map(|a| AgentConfigInfoResponse {
|
||||||
name: a.name.clone(),
|
name: a.name.clone(),
|
||||||
role: a.role.clone(),
|
role: a.role.clone(),
|
||||||
|
stage: a.stage.clone(),
|
||||||
model: a.model.clone(),
|
model: a.model.clone(),
|
||||||
allowed_tools: a.allowed_tools.clone(),
|
allowed_tools: a.allowed_tools.clone(),
|
||||||
max_turns: a.max_turns,
|
max_turns: a.max_turns,
|
||||||
@@ -304,6 +306,7 @@ impl AgentsApi {
|
|||||||
.map(|a| AgentConfigInfoResponse {
|
.map(|a| AgentConfigInfoResponse {
|
||||||
name: a.name.clone(),
|
name: a.name.clone(),
|
||||||
role: a.role.clone(),
|
role: a.role.clone(),
|
||||||
|
stage: a.stage.clone(),
|
||||||
model: a.model.clone(),
|
model: a.model.clone(),
|
||||||
allowed_tools: a.allowed_tools.clone(),
|
allowed_tools: a.allowed_tools.clone(),
|
||||||
max_turns: a.max_turns,
|
max_turns: a.max_turns,
|
||||||
|
|||||||
Reference in New Issue
Block a user