story-kit: merge 336_story_web_ui_button_to_start_a_coder_on_a_story
This commit is contained in:
@@ -2,6 +2,8 @@ import * as React from "react";
|
|||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
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 type { PipelineState } from "../api/client";
|
||||||
import { api, ChatWebSocket } from "../api/client";
|
import { api, ChatWebSocket } from "../api/client";
|
||||||
import { useChatHistory } from "../hooks/useChatHistory";
|
import { useChatHistory } from "../hooks/useChatHistory";
|
||||||
@@ -202,6 +204,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const [agentConfigVersion, setAgentConfigVersion] = useState(0);
|
const [agentConfigVersion, setAgentConfigVersion] = useState(0);
|
||||||
const [agentStateVersion, setAgentStateVersion] = useState(0);
|
const [agentStateVersion, setAgentStateVersion] = useState(0);
|
||||||
const [pipelineVersion, setPipelineVersion] = useState(0);
|
const [pipelineVersion, setPipelineVersion] = useState(0);
|
||||||
|
const [agentRoster, setAgentRoster] = useState<AgentConfigInfo[]>([]);
|
||||||
const [storyTokenCosts, setStoryTokenCosts] = useState<Map<string, number>>(
|
const [storyTokenCosts, setStoryTokenCosts] = useState<Map<string, number>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
@@ -237,6 +240,26 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const userScrolledUpRef = useRef(false);
|
const userScrolledUpRef = useRef(false);
|
||||||
const pendingMessageRef = useRef<string>("");
|
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(() => {
|
const contextUsage = useMemo(() => {
|
||||||
let totalTokens = 0;
|
let totalTokens = 0;
|
||||||
totalTokens += 200;
|
totalTokens += 200;
|
||||||
@@ -504,6 +527,27 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
return () => window.removeEventListener("resize", handleResize);
|
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 () => {
|
const cancelGeneration = async () => {
|
||||||
// Preserve queued messages by appending them to the chat input box
|
// Preserve queued messages by appending them to the chat input box
|
||||||
if (queuedMessagesRef.current.length > 0) {
|
if (queuedMessagesRef.current.length > 0) {
|
||||||
@@ -1051,12 +1095,18 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
items={pipeline.current}
|
items={pipeline.current}
|
||||||
costs={storyTokenCosts}
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
|
agentRoster={agentRoster}
|
||||||
|
busyAgentNames={busyAgentNames}
|
||||||
|
onStartAgent={handleStartAgent}
|
||||||
/>
|
/>
|
||||||
<StagePanel
|
<StagePanel
|
||||||
title="Backlog"
|
title="Backlog"
|
||||||
items={pipeline.backlog}
|
items={pipeline.backlog}
|
||||||
costs={storyTokenCosts}
|
costs={storyTokenCosts}
|
||||||
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
|
agentRoster={agentRoster}
|
||||||
|
busyAgentNames={busyAgentNames}
|
||||||
|
onStartAgent={handleStartAgent}
|
||||||
/>
|
/>
|
||||||
<ServerLogsPanel logs={serverLogs} />
|
<ServerLogsPanel logs={serverLogs} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import type { AgentConfigInfo } from "../api/agents";
|
||||||
import type { AgentAssignment, PipelineStageItem } from "../api/client";
|
import type { AgentAssignment, PipelineStageItem } from "../api/client";
|
||||||
import { useLozengeFly } from "./LozengeFlyContext";
|
import { useLozengeFly } from "./LozengeFlyContext";
|
||||||
|
|
||||||
const { useLayoutEffect, useRef } = React;
|
const { useLayoutEffect, useRef, useState } = React;
|
||||||
|
|
||||||
type WorkItemType = "story" | "bug" | "spike" | "refactor" | "unknown";
|
type WorkItemType = "story" | "bug" | "spike" | "refactor" | "unknown";
|
||||||
|
|
||||||
@@ -44,6 +45,12 @@ interface StagePanelProps {
|
|||||||
onItemClick?: (item: PipelineStageItem) => void;
|
onItemClick?: (item: PipelineStageItem) => void;
|
||||||
/** Map of story_id → total_cost_usd for displaying cost badges. */
|
/** Map of story_id → total_cost_usd for displaying cost badges. */
|
||||||
costs?: Map<string, number>;
|
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({
|
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({
|
export function StagePanel({
|
||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
emptyMessage = "Empty.",
|
emptyMessage = "Empty.",
|
||||||
onItemClick,
|
onItemClick,
|
||||||
costs,
|
costs,
|
||||||
|
agentRoster,
|
||||||
|
busyAgentNames,
|
||||||
|
onStartAgent,
|
||||||
}: StagePanelProps) {
|
}: StagePanelProps) {
|
||||||
|
const showStartButton =
|
||||||
|
Boolean(onStartAgent) &&
|
||||||
|
agentRoster !== undefined &&
|
||||||
|
agentRoster.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -201,6 +303,10 @@ export function StagePanel({
|
|||||||
font: "inherit",
|
font: "inherit",
|
||||||
cursor: onItemClick ? "pointer" : "default",
|
cursor: onItemClick ? "pointer" : "default",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only offer "Start" when the item has no assigned agent
|
||||||
|
const canStart = showStartButton && !item.agent;
|
||||||
|
|
||||||
const cardInner = (
|
const cardInner = (
|
||||||
<>
|
<>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@@ -287,6 +393,14 @@ export function StagePanel({
|
|||||||
{item.agent && (
|
{item.agent && (
|
||||||
<AgentLozenge agent={item.agent} storyId={item.story_id} />
|
<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 ? (
|
return onItemClick ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user