story-kit: merge 336_story_web_ui_button_to_start_a_coder_on_a_story

This commit is contained in:
Dave
2026-03-20 08:49:35 +00:00
parent 31e2f823f7
commit e33979aacb
2 changed files with 165 additions and 1 deletions

View File

@@ -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<AgentConfigInfo[]>([]);
const [storyTokenCosts, setStoryTokenCosts] = useState<Map<string, number>>(
new Map(),
);
@@ -237,6 +240,26 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const userScrolledUpRef = useRef(false);
const pendingMessageRef = useRef<string>("");
// Agents currently running or pending across all pipeline stages.
const busyAgentNames = useMemo(() => {
const busy = new Set<string>();
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}
/>
<StagePanel
title="Backlog"
items={pipeline.backlog}
costs={storyTokenCosts}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
agentRoster={agentRoster}
busyAgentNames={busyAgentNames}
onStartAgent={handleStartAgent}
/>
<ServerLogsPanel logs={serverLogs} />
</>

View File

@@ -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<string, number>;
/** Agent roster to populate the start agent dropdown. */
agentRoster?: AgentConfigInfo[];
/** Names of agents currently running/pending (busy). */
busyAgentNames?: Set<string>;
/** 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<string>;
onStartAgent: (storyId: string, agentName?: string) => void;
}) {
const [selectedAgent, setSelectedAgent] = useState<string>("");
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<HTMLSelectElement>) => {
e.stopPropagation();
setSelectedAgent(e.target.value);
};
return (
<div
style={{
display: "flex",
gap: "4px",
marginTop: "6px",
alignItems: "center",
}}
>
{agentRoster.length > 1 && (
<select
value={selectedAgent}
onChange={handleSelectChange}
disabled={allBusy}
data-testid={`start-agent-select-${storyId}`}
style={{
background: "#2a2a2a",
color: allBusy ? "#555" : "#ccc",
border: "1px solid #444",
borderRadius: "5px",
padding: "2px 4px",
fontSize: "0.75em",
cursor: allBusy ? "not-allowed" : "pointer",
flex: 1,
minWidth: 0,
}}
>
<option value="">Default agent</option>
{agentRoster.map((a) => (
<option key={a.name} value={a.name}>
{a.name}
</option>
))}
</select>
)}
<button
type="button"
onClick={handleStart}
disabled={allBusy}
data-testid={`start-agent-btn-${storyId}`}
title={allBusy ? "All agents are busy" : "Start a coder on this story"}
style={{
background: allBusy ? "#1a1a1a" : "#1a3a1a",
color: allBusy ? "#555" : "#3fb950",
border: `1px solid ${allBusy ? "#333" : "#2a5a2a"}`,
borderRadius: "5px",
padding: "2px 8px",
fontSize: "0.75em",
fontWeight: 600,
cursor: allBusy ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
Start
</button>
</div>
);
}
export function StagePanel({
title,
items,
emptyMessage = "Empty.",
onItemClick,
costs,
agentRoster,
busyAgentNames,
onStartAgent,
}: StagePanelProps) {
const showStartButton =
Boolean(onStartAgent) &&
agentRoster !== undefined &&
agentRoster.length > 0;
return (
<div
style={{
@@ -201,6 +303,10 @@ export function StagePanel({
font: "inherit",
cursor: onItemClick ? "pointer" : "default",
};
// Only offer "Start" when the item has no assigned agent
const canStart = showStartButton && !item.agent;
const cardInner = (
<>
<div style={{ flex: 1 }}>
@@ -287,6 +393,14 @@ export function StagePanel({
{item.agent && (
<AgentLozenge agent={item.agent} storyId={item.story_id} />
)}
{canStart && onStartAgent && (
<StartAgentControl
storyId={item.story_id}
agentRoster={agentRoster ?? []}
busyAgentNames={busyAgentNames ?? new Set()}
onStartAgent={onStartAgent}
/>
)}
</>
);
return onItemClick ? (