Accept story 30: Worktree-based agent orchestration
Add git worktree isolation for concurrent story agents. Each agent now runs in its own worktree with setup/teardown commands driven by .story_kit/project.toml config. Agents stream output via SSE and support start/stop lifecycle with Pending/Running/Completed/Failed statuses. Backend: config.rs (TOML parsing), worktree.rs (git worktree lifecycle), refactored agents.rs (broadcast streaming), agents_sse.rs (SSE endpoint). Frontend: AgentPanel.tsx with Run/Stop buttons and streaming output log. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
470
frontend/src/components/AgentPanel.tsx
Normal file
470
frontend/src/components/AgentPanel.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import * as React from "react";
|
||||
import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents";
|
||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||
import type { UpcomingStory } from "../api/workflow";
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
interface AgentPanelProps {
|
||||
stories: UpcomingStory[];
|
||||
}
|
||||
|
||||
interface AgentState {
|
||||
status: AgentStatusValue;
|
||||
log: string[];
|
||||
sessionId: string | null;
|
||||
worktreePath: string | null;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<AgentStatusValue, string> = {
|
||||
pending: "#e3b341",
|
||||
running: "#58a6ff",
|
||||
completed: "#7ee787",
|
||||
failed: "#ff7b72",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<AgentStatusValue, string> = {
|
||||
pending: "Pending",
|
||||
running: "Running",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
};
|
||||
|
||||
const formatTimestamp = (value: Date | null): string => {
|
||||
if (!value) return "";
|
||||
return value.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: AgentStatusValue }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "999px",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
background: `${STATUS_COLORS[status]}22`,
|
||||
color: STATUS_COLORS[status],
|
||||
border: `1px solid ${STATUS_COLORS[status]}44`,
|
||||
}}
|
||||
>
|
||||
{status === "running" && (
|
||||
<span
|
||||
style={{
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
background: STATUS_COLORS[status],
|
||||
animation: "pulse 1.5s infinite",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{STATUS_LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPanel({ stories }: AgentPanelProps) {
|
||||
const [agents, setAgents] = useState<Record<string, AgentState>>({});
|
||||
const [expandedStory, setExpandedStory] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
const cleanupRefs = useRef<Record<string, () => void>>({});
|
||||
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
// Load existing agents on mount
|
||||
useEffect(() => {
|
||||
agentsApi
|
||||
.listAgents()
|
||||
.then((agentList) => {
|
||||
const agentMap: Record<string, AgentState> = {};
|
||||
for (const a of agentList) {
|
||||
agentMap[a.story_id] = {
|
||||
status: a.status,
|
||||
log: [],
|
||||
sessionId: a.session_id,
|
||||
worktreePath: a.worktree_path,
|
||||
};
|
||||
// Re-subscribe to running agents
|
||||
if (a.status === "running" || a.status === "pending") {
|
||||
subscribeToAgent(a.story_id);
|
||||
}
|
||||
}
|
||||
setAgents(agentMap);
|
||||
setLastRefresh(new Date());
|
||||
})
|
||||
.catch((err) => console.error("Failed to load agents:", err));
|
||||
|
||||
return () => {
|
||||
for (const cleanup of Object.values(cleanupRefs.current)) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const subscribeToAgent = useCallback((storyId: string) => {
|
||||
// Clean up existing subscription
|
||||
cleanupRefs.current[storyId]?.();
|
||||
|
||||
const cleanup = subscribeAgentStream(
|
||||
storyId,
|
||||
(event: AgentEvent) => {
|
||||
setAgents((prev) => {
|
||||
const current = prev[storyId] ?? {
|
||||
status: "pending" as AgentStatusValue,
|
||||
log: [],
|
||||
sessionId: null,
|
||||
worktreePath: null,
|
||||
};
|
||||
|
||||
switch (event.type) {
|
||||
case "status":
|
||||
return {
|
||||
...prev,
|
||||
[storyId]: {
|
||||
...current,
|
||||
status: (event.status as AgentStatusValue) ?? current.status,
|
||||
},
|
||||
};
|
||||
case "output":
|
||||
return {
|
||||
...prev,
|
||||
[storyId]: {
|
||||
...current,
|
||||
log: [...current.log, event.text ?? ""],
|
||||
},
|
||||
};
|
||||
case "done":
|
||||
return {
|
||||
...prev,
|
||||
[storyId]: {
|
||||
...current,
|
||||
status: "completed",
|
||||
sessionId: event.session_id ?? current.sessionId,
|
||||
},
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
...prev,
|
||||
[storyId]: {
|
||||
...current,
|
||||
status: "failed",
|
||||
log: [
|
||||
...current.log,
|
||||
`[ERROR] ${event.message ?? "Unknown error"}`,
|
||||
],
|
||||
},
|
||||
};
|
||||
default:
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
},
|
||||
() => {
|
||||
// SSE error — agent may not be streaming yet
|
||||
},
|
||||
);
|
||||
|
||||
cleanupRefs.current[storyId] = cleanup;
|
||||
}, []);
|
||||
|
||||
// Auto-scroll log when expanded
|
||||
useEffect(() => {
|
||||
if (expandedStory) {
|
||||
const el = logEndRefs.current[expandedStory];
|
||||
el?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [expandedStory, agents]);
|
||||
|
||||
const handleStart = async (storyId: string) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
const info: AgentInfo = await agentsApi.startAgent(storyId);
|
||||
setAgents((prev) => ({
|
||||
...prev,
|
||||
[storyId]: {
|
||||
status: info.status,
|
||||
log: [],
|
||||
sessionId: info.session_id,
|
||||
worktreePath: info.worktree_path,
|
||||
},
|
||||
}));
|
||||
setExpandedStory(storyId);
|
||||
subscribeToAgent(storyId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setActionError(`Failed to start agent for ${storyId}: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (storyId: string) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await agentsApi.stopAgent(storyId);
|
||||
cleanupRefs.current[storyId]?.();
|
||||
delete cleanupRefs.current[storyId];
|
||||
setAgents((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[storyId];
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setActionError(`Failed to stop agent for ${storyId}: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const isAgentActive = (storyId: string): boolean => {
|
||||
const agent = agents[storyId];
|
||||
return agent?.status === "running" || agent?.status === "pending";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #333",
|
||||
borderRadius: "10px",
|
||||
padding: "12px 16px",
|
||||
background: "#1f1f1f",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>Agents</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#777",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{Object.values(agents).filter((a) => a.status === "running").length}{" "}
|
||||
running
|
||||
</div>
|
||||
</div>
|
||||
{lastRefresh && (
|
||||
<div style={{ fontSize: "0.7em", color: "#555" }}>
|
||||
Loaded {formatTimestamp(lastRefresh)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: "#ff7b72",
|
||||
padding: "4px 8px",
|
||||
background: "#ff7b7211",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stories.length === 0 ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
No stories available. Add stories to .story_kit/stories/upcoming/.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{stories.map((story) => {
|
||||
const agent = agents[story.story_id];
|
||||
const isExpanded = expandedStory === story.story_id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`agent-${story.story_id}`}
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
background: "#191919",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedStory(isExpanded ? null : story.story_id)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
setExpandedStory(isExpanded ? null : story.story_id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8em",
|
||||
padding: "0 4px",
|
||||
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.15s",
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9em",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{story.name ?? story.story_id}
|
||||
</div>
|
||||
|
||||
{agent && <StatusBadge status={agent.status} />}
|
||||
|
||||
{isAgentActive(story.story_id) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStop(story.story_id)}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #ff7b7244",
|
||||
background: "#ff7b7211",
|
||||
color: "#ff7b72",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStart(story.story_id)}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #7ee78744",
|
||||
background: "#7ee78711",
|
||||
color: "#7ee787",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && agent && (
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #2a2a2a",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
>
|
||||
{agent.worktreePath && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#666",
|
||||
fontFamily: "monospace",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
Worktree: {agent.worktreePath}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto",
|
||||
background: "#111",
|
||||
borderRadius: "6px",
|
||||
padding: "8px",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8em",
|
||||
lineHeight: "1.5",
|
||||
color: "#ccc",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{agent.log.length === 0 ? (
|
||||
<span style={{ color: "#555" }}>
|
||||
{agent.status === "pending" ||
|
||||
agent.status === "running"
|
||||
? "Waiting for output..."
|
||||
: "No output captured."}
|
||||
</span>
|
||||
) : (
|
||||
agent.log.map((line, i) => (
|
||||
<div
|
||||
key={`log-${story.story_id}-${i}`}
|
||||
style={{
|
||||
color: line.startsWith("[ERROR]")
|
||||
? "#ff7b72"
|
||||
: "#ccc",
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div
|
||||
ref={(el) => {
|
||||
logEndRefs.current[story.story_id] = el;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user