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:
Dave
2026-02-20 19:39:19 +00:00
parent 65b104edc5
commit 810608d3d8
29 changed files with 1041 additions and 4526 deletions

View File

@@ -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",
}}
>
&#9654;
</button>
&#9654;
</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>