Spike 61: filesystem watcher and UI simplification
Add notify-based filesystem watcher for .story_kit/work/ that auto-commits changes with deterministic messages and broadcasts events over WebSocket. Push full pipeline state (Upcoming, Current, QA, To Merge) to frontend on connect and after every watcher event. Strip dead UI: remove ReviewPanel, GatePanel, TodoPanel, UpcomingPanel and all associated REST polling. Replace with 4 generic StagePanel components driven by WebSocket. Simplify AgentPanel to roster-only. Delete all 11 workflow HTTP endpoints and 16 request/response types from the server. Clean dead code from workflow module. MCP tools call Rust functions directly and need none of the HTTP layer. Net: ~4,100 lines deleted, ~400 added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,19 +2,13 @@ import * as React from "react";
|
||||
import type {
|
||||
AgentConfigInfo,
|
||||
AgentEvent,
|
||||
AgentInfo,
|
||||
AgentStatusValue,
|
||||
} from "../api/agents";
|
||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||
import { settingsApi } from "../api/settings";
|
||||
import type { UpcomingStory } from "../api/workflow";
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
interface AgentPanelProps {
|
||||
stories: UpcomingStory[];
|
||||
}
|
||||
|
||||
interface AgentState {
|
||||
agentName: string;
|
||||
status: AgentStatusValue;
|
||||
@@ -238,13 +232,12 @@ export function EditorCommand({
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPanel({ stories }: AgentPanelProps) {
|
||||
export function AgentPanel() {
|
||||
const [agents, setAgents] = useState<Record<string, AgentState>>({});
|
||||
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
const [selectorStory, setSelectorStory] = useState<string | null>(null);
|
||||
const [editorCommand, setEditorCommand] = useState<string | null>(null);
|
||||
const [editorInput, setEditorInput] = useState<string>("");
|
||||
const [editingEditor, setEditingEditor] = useState(false);
|
||||
@@ -374,31 +367,6 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
||||
}
|
||||
}, [expandedKey, agents]);
|
||||
|
||||
const handleStart = async (storyId: string, agentName?: string) => {
|
||||
setActionError(null);
|
||||
setSelectorStory(null);
|
||||
try {
|
||||
const info: AgentInfo = await agentsApi.startAgent(storyId, agentName);
|
||||
const key = agentKey(info.story_id, info.agent_name);
|
||||
setAgents((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
agentName: info.agent_name,
|
||||
status: info.status,
|
||||
log: [],
|
||||
sessionId: info.session_id,
|
||||
worktreePath: info.worktree_path,
|
||||
baseBranch: info.base_branch,
|
||||
},
|
||||
}));
|
||||
setExpandedKey(key);
|
||||
subscribeToAgent(info.story_id, info.agent_name);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setActionError(`Failed to start agent for ${storyId}: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (storyId: string, agentName: string) => {
|
||||
setActionError(null);
|
||||
const key = agentKey(storyId, agentName);
|
||||
@@ -417,14 +385,6 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunClick = (storyId: string) => {
|
||||
if (roster.length <= 1) {
|
||||
handleStart(storyId);
|
||||
} else {
|
||||
setSelectorStory(selectorStory === storyId ? null : storyId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEditor = async () => {
|
||||
try {
|
||||
const trimmed = editorInput.trim() || null;
|
||||
@@ -438,17 +398,6 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
/** Get all active agent keys for a story. */
|
||||
const getActiveKeysForStory = (storyId: string): string[] => {
|
||||
return Object.keys(agents).filter((key) => {
|
||||
const a = agents[key];
|
||||
return (
|
||||
key.startsWith(`${storyId}:`) &&
|
||||
(a.status === "running" || a.status === "pending")
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -599,11 +548,8 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stories.length === 0 ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
No stories available. Add stories to .story_kit/stories/upcoming/.
|
||||
</div>
|
||||
) : (
|
||||
{/* Active agents */}
|
||||
{Object.entries(agents).length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -611,317 +557,156 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{stories.map((story) => {
|
||||
const activeKeys = getActiveKeysForStory(story.story_id);
|
||||
const hasActive = activeKeys.length > 0;
|
||||
|
||||
// Gather all agent states for this story
|
||||
const storyAgentEntries = Object.entries(agents).filter(([key]) =>
|
||||
key.startsWith(`${story.story_id}:`),
|
||||
);
|
||||
|
||||
return (
|
||||
{Object.entries(agents).map(([key, a]) => (
|
||||
<div
|
||||
key={`agent-${key}`}
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
background: "#191919",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
key={`agent-${story.story_id}`}
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
background: "#191919",
|
||||
overflow: "hidden",
|
||||
padding: "8px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedKey(expandedKey === key ? null : key)
|
||||
}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8em",
|
||||
padding: "0 4px",
|
||||
transform:
|
||||
expandedKey === key
|
||||
? "rotate(90deg)"
|
||||
: "rotate(0deg)",
|
||||
transition: "transform 0.15s",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const isExpanded =
|
||||
expandedKey?.startsWith(`${story.story_id}:`) ||
|
||||
expandedKey === story.story_id;
|
||||
setExpandedKey(
|
||||
isExpanded
|
||||
? null
|
||||
: (storyAgentEntries[0]?.[0] ?? story.story_id),
|
||||
);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
const isExpanded =
|
||||
expandedKey?.startsWith(`${story.story_id}:`) ||
|
||||
expandedKey === story.story_id;
|
||||
setExpandedKey(
|
||||
isExpanded
|
||||
? null
|
||||
: (storyAgentEntries[0]?.[0] ?? story.story_id),
|
||||
);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8em",
|
||||
padding: "0 4px",
|
||||
transform:
|
||||
expandedKey?.startsWith(`${story.story_id}:`) ||
|
||||
expandedKey === story.story_id
|
||||
? "rotate(90deg)"
|
||||
: "rotate(0deg)",
|
||||
transition: "transform 0.15s",
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
▶
|
||||
</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9em",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{story.name ?? story.story_id}
|
||||
</div>
|
||||
|
||||
{storyAgentEntries.map(([key, a]) => (
|
||||
<span
|
||||
key={`badge-${key}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7em",
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
{a.agentName}
|
||||
</span>
|
||||
<StatusBadge status={a.status} />
|
||||
</span>
|
||||
))}
|
||||
|
||||
{hasActive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
for (const key of activeKeys) {
|
||||
const a = agents[key];
|
||||
if (a) {
|
||||
handleStop(story.story_id, a.agentName);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #ff7b7244",
|
||||
background: "#ff7b7211",
|
||||
color: "#ff7b72",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRunClick(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>
|
||||
{selectorStory === story.story_id &&
|
||||
roster.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
right: 0,
|
||||
marginTop: "4px",
|
||||
background: "#222",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "6px",
|
||||
padding: "4px 0",
|
||||
zIndex: 10,
|
||||
minWidth: "160px",
|
||||
}}
|
||||
>
|
||||
{roster.map((r) => (
|
||||
<button
|
||||
key={`sel-${r.name}`}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleStart(story.story_id, r.name)
|
||||
}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "6px 12px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#ccc",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
fontSize: "0.8em",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(
|
||||
e.target as HTMLButtonElement
|
||||
).style.background = "#333";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(
|
||||
e.target as HTMLButtonElement
|
||||
).style.background = "none";
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{r.name}</div>
|
||||
{r.role && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
{r.role}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9em",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#888" }}>{a.agentName}</span>
|
||||
<span style={{ color: "#555", margin: "0 6px" }}>
|
||||
{key.split(":")[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Empty state when expanded with no agents */}
|
||||
{expandedKey === story.story_id &&
|
||||
storyAgentEntries.length === 0 && (
|
||||
<StatusBadge status={a.status} />
|
||||
|
||||
{(a.status === "running" || a.status === "pending") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleStop(key.split(":")[0], a.agentName)
|
||||
}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #ff7b7244",
|
||||
background: "#ff7b7211",
|
||||
color: "#ff7b72",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expandedKey === key && (
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #2a2a2a",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
>
|
||||
{a.worktreePath && (
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #2a2a2a",
|
||||
padding: "12px",
|
||||
fontSize: "0.8em",
|
||||
color: "#555",
|
||||
textAlign: "center",
|
||||
fontSize: "0.75em",
|
||||
color: "#666",
|
||||
fontFamily: "monospace",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
No agents started. Use the Run button to start an agent.
|
||||
Worktree: {a.worktreePath}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded detail per agent */}
|
||||
{storyAgentEntries.map(([key, a]) => {
|
||||
if (expandedKey !== key) return null;
|
||||
return (
|
||||
<div
|
||||
key={`detail-${key}`}
|
||||
style={{
|
||||
borderTop: "1px solid #2a2a2a",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#888",
|
||||
marginBottom: "4px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{a.agentName}
|
||||
</div>
|
||||
{a.worktreePath && (
|
||||
{a.worktreePath && (
|
||||
<DiffCommand
|
||||
worktreePath={a.worktreePath}
|
||||
baseBranch={a.baseBranch ?? "master"}
|
||||
/>
|
||||
)}
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
{a.log.length === 0 ? (
|
||||
<span style={{ color: "#555" }}>
|
||||
{a.status === "pending" || a.status === "running"
|
||||
? "Waiting for output..."
|
||||
: "No output captured."}
|
||||
</span>
|
||||
) : (
|
||||
a.log.map((line, i) => (
|
||||
<div
|
||||
key={`log-${key}-${i.toString()}`}
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#666",
|
||||
fontFamily: "monospace",
|
||||
marginBottom: "6px",
|
||||
color: line.startsWith("[ERROR]")
|
||||
? "#ff7b72"
|
||||
: "#ccc",
|
||||
}}
|
||||
>
|
||||
Worktree: {a.worktreePath}
|
||||
{line}
|
||||
</div>
|
||||
)}
|
||||
{a.worktreePath && (
|
||||
<DiffCommand
|
||||
worktreePath={a.worktreePath}
|
||||
baseBranch={a.baseBranch ?? "master"}
|
||||
/>
|
||||
)}
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
{a.log.length === 0 ? (
|
||||
<span style={{ color: "#555" }}>
|
||||
{a.status === "pending" || a.status === "running"
|
||||
? "Waiting for output..."
|
||||
: "No output captured."}
|
||||
</span>
|
||||
) : (
|
||||
a.log.map((line, i) => (
|
||||
<div
|
||||
key={`log-${key}-${i.toString()}`}
|
||||
style={{
|
||||
color: line.startsWith("[ERROR]")
|
||||
? "#ff7b72"
|
||||
: "#ccc",
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div
|
||||
ref={(el) => {
|
||||
logEndRefs.current[key] = el;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))
|
||||
)}
|
||||
<div
|
||||
ref={(el) => {
|
||||
logEndRefs.current[key] = el;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user