diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 410dfed..6c765c8 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -2,6 +2,8 @@ import * as React from "react"; import Markdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import type { AgentConfigInfo } from "../api/agents"; +import { agentsApi } from "../api/agents"; import type { PipelineState } from "../api/client"; import { api, ChatWebSocket } from "../api/client"; import { useChatHistory } from "../hooks/useChatHistory"; @@ -202,6 +204,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [agentConfigVersion, setAgentConfigVersion] = useState(0); const [agentStateVersion, setAgentStateVersion] = useState(0); const [pipelineVersion, setPipelineVersion] = useState(0); + const [agentRoster, setAgentRoster] = useState([]); const [storyTokenCosts, setStoryTokenCosts] = useState>( new Map(), ); @@ -237,6 +240,26 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const userScrolledUpRef = useRef(false); const pendingMessageRef = useRef(""); + // Agents currently running or pending across all pipeline stages. + const busyAgentNames = useMemo(() => { + const busy = new Set(); + const allItems = [ + ...pipeline.backlog, + ...pipeline.current, + ...pipeline.qa, + ...pipeline.merge, + ]; + for (const item of allItems) { + if ( + item.agent && + (item.agent.status === "running" || item.agent.status === "pending") + ) { + busy.add(item.agent.agent_name); + } + } + return busy; + }, [pipeline]); + const contextUsage = useMemo(() => { let totalTokens = 0; totalTokens += 200; @@ -504,6 +527,27 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { return () => window.removeEventListener("resize", handleResize); }, []); + // Fetch agent roster whenever the config changes so the start button knows available agents. + useEffect(() => { + agentsApi + .getAgentConfig() + .then(setAgentRoster) + .catch(() => { + // Silently ignore — roster unavailable. + }); + }, [agentConfigVersion]); + + const handleStartAgent = useCallback( + async (storyId: string, agentName?: string) => { + try { + await agentsApi.startAgent(storyId, agentName); + } catch (err) { + console.error("Failed to start agent:", err); + } + }, + [], + ); + const cancelGeneration = async () => { // Preserve queued messages by appending them to the chat input box if (queuedMessagesRef.current.length > 0) { @@ -1051,12 +1095,18 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { items={pipeline.current} costs={storyTokenCosts} onItemClick={(item) => setSelectedWorkItemId(item.story_id)} + agentRoster={agentRoster} + busyAgentNames={busyAgentNames} + onStartAgent={handleStartAgent} /> setSelectedWorkItemId(item.story_id)} + agentRoster={agentRoster} + busyAgentNames={busyAgentNames} + onStartAgent={handleStartAgent} /> diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index 91ae51a..d0b7766 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -1,8 +1,9 @@ import * as React from "react"; +import type { AgentConfigInfo } from "../api/agents"; import type { AgentAssignment, PipelineStageItem } from "../api/client"; import { useLozengeFly } from "./LozengeFlyContext"; -const { useLayoutEffect, useRef } = React; +const { useLayoutEffect, useRef, useState } = React; type WorkItemType = "story" | "bug" | "spike" | "refactor" | "unknown"; @@ -44,6 +45,12 @@ interface StagePanelProps { onItemClick?: (item: PipelineStageItem) => void; /** Map of story_id → total_cost_usd for displaying cost badges. */ costs?: Map; + /** Agent roster to populate the start agent dropdown. */ + agentRoster?: AgentConfigInfo[]; + /** Names of agents currently running/pending (busy). */ + busyAgentNames?: Set; + /** Called when the user requests to start an agent on a story. */ + onStartAgent?: (storyId: string, agentName?: string) => void; } function AgentLozenge({ @@ -125,13 +132,108 @@ function AgentLozenge({ ); } +function StartAgentControl({ + storyId, + agentRoster, + busyAgentNames, + onStartAgent, +}: { + storyId: string; + agentRoster: AgentConfigInfo[]; + busyAgentNames: Set; + onStartAgent: (storyId: string, agentName?: string) => void; +}) { + const [selectedAgent, setSelectedAgent] = useState(""); + + const allBusy = + agentRoster.length > 0 && + agentRoster.every((a) => busyAgentNames.has(a.name)); + + const handleStart = (e: React.MouseEvent) => { + e.stopPropagation(); + onStartAgent(storyId, selectedAgent || undefined); + }; + + const handleSelectChange = (e: React.ChangeEvent) => { + e.stopPropagation(); + setSelectedAgent(e.target.value); + }; + + return ( +
+ {agentRoster.length > 1 && ( + + )} + +
+ ); +} + export function StagePanel({ title, items, emptyMessage = "Empty.", onItemClick, costs, + agentRoster, + busyAgentNames, + onStartAgent, }: StagePanelProps) { + const showStartButton = + Boolean(onStartAgent) && + agentRoster !== undefined && + agentRoster.length > 0; + return (
@@ -287,6 +393,14 @@ export function StagePanel({ {item.agent && ( )} + {canStart && onStartAgent && ( + + )} ); return onItemClick ? (