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

@@ -3,15 +3,11 @@ import Markdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { api, ChatWebSocket } from "../api/client";
import type { ReviewStory, UpcomingStory } from "../api/workflow";
import { workflowApi } from "../api/workflow";
import type { PipelineState } from "../api/client";
import type { Message, ProviderConfig, ToolCall } from "../types";
import { AgentPanel } from "./AgentPanel";
import { ChatHeader } from "./ChatHeader";
import { GatePanel } from "./GatePanel";
import { ReviewPanel } from "./ReviewPanel";
import { TodoPanel } from "./TodoPanel";
import { UpcomingPanel } from "./UpcomingPanel";
import { StagePanel } from "./StagePanel";
const { useCallback, useEffect, useRef, useState } = React;
@@ -22,23 +18,6 @@ interface ChatProps {
onCloseProject: () => void;
}
interface GateState {
canAccept: boolean;
reasons: string[];
warning: string | null;
summary: {
total: number;
passed: number;
failed: number;
};
missingCategories: string[];
coverageReport: {
currentPercent: number;
thresholdPercent: number;
baselinePercent: number | null;
} | null;
}
export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
@@ -51,55 +30,17 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
const [apiKeyInput, setApiKeyInput] = useState("");
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
const [gateState, setGateState] = useState<GateState | null>(null);
const [gateError, setGateError] = useState<string | null>(null);
const [isGateLoading, setIsGateLoading] = useState(false);
const [reviewQueue, setReviewQueue] = useState<ReviewStory[]>([]);
const [reviewError, setReviewError] = useState<string | null>(null);
const [isReviewLoading, setIsReviewLoading] = useState(false);
const [proceedingStoryId, setProceedingStoryId] = useState<string | null>(
null,
);
const [proceedError, setProceedError] = useState<string | null>(null);
const [proceedSuccess, setProceedSuccess] = useState<string | null>(null);
const [lastReviewRefresh, setLastReviewRefresh] = useState<Date | null>(null);
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
const [isCollectingCoverage, setIsCollectingCoverage] = useState(false);
const [coverageError, setCoverageError] = useState<string | null>(null);
const [storyTodos, setStoryTodos] = useState<
{
storyId: string;
storyName: string | null;
items: string[];
error: string | null;
}[]
>([]);
const [todoError, setTodoError] = useState<string | null>(null);
const [isTodoLoading, setIsTodoLoading] = useState(false);
const [lastTodoRefresh, setLastTodoRefresh] = useState<Date | null>(null);
const [upcomingStories, setUpcomingStories] = useState<UpcomingStory[]>([]);
const [upcomingError, setUpcomingError] = useState<string | null>(null);
const [isUpcomingLoading, setIsUpcomingLoading] = useState(false);
const [lastUpcomingRefresh, setLastUpcomingRefresh] = useState<Date | null>(
null,
);
const [pipeline, setPipeline] = useState<PipelineState>({
upcoming: [],
current: [],
qa: [],
merge: [],
});
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
const [isNarrowScreen, setIsNarrowScreen] = useState(
window.innerWidth < NARROW_BREAKPOINT,
);
const storyId = "26_establish_tdd_workflow_and_gates";
const gateStatusColor = isGateLoading
? "#aaa"
: gateState?.canAccept
? "#7ee787"
: "#ff7b72";
const gateStatusLabel = isGateLoading
? "Checking..."
: gateState?.canAccept
? "Ready to accept"
: "Blocked";
const wsRef = useRef<ChatWebSocket | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -198,293 +139,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
});
}, []);
useEffect(() => {
let active = true;
setIsGateLoading(true);
setGateError(null);
workflowApi
.getAcceptance({ story_id: storyId })
.then((response) => {
if (!active) return;
setGateState({
canAccept: response.can_accept,
reasons: response.reasons,
warning: response.warning ?? null,
summary: response.summary,
missingCategories: response.missing_categories,
coverageReport: response.coverage_report
? {
currentPercent: response.coverage_report.current_percent,
thresholdPercent: response.coverage_report.threshold_percent,
baselinePercent:
response.coverage_report.baseline_percent ?? null,
}
: null,
});
setLastGateRefresh(new Date());
})
.catch((error) => {
if (!active) return;
const message =
error instanceof Error
? error.message
: "Failed to load workflow gates.";
setGateError(message);
setGateState(null);
})
.finally(() => {
if (active) {
setIsGateLoading(false);
}
});
return () => {
active = false;
};
}, [storyId]);
useEffect(() => {
let active = true;
setIsReviewLoading(true);
setReviewError(null);
workflowApi
.getReviewQueueAll()
.then((response) => {
if (!active) return;
setReviewQueue(response.stories);
setLastReviewRefresh(new Date());
})
.catch((error) => {
if (!active) return;
const message =
error instanceof Error
? error.message
: "Failed to load review queue.";
setReviewError(message);
setReviewQueue([]);
})
.finally(() => {
if (active) {
setIsReviewLoading(false);
}
});
return () => {
active = false;
};
}, []);
useEffect(() => {
let active = true;
setIsTodoLoading(true);
setTodoError(null);
workflowApi
.getStoryTodos()
.then((response) => {
if (!active) return;
setStoryTodos(
response.stories.map((s) => ({
storyId: s.story_id,
storyName: s.story_name,
items: s.todos,
error: s.error ?? null,
})),
);
setLastTodoRefresh(new Date());
})
.catch((error) => {
if (!active) return;
const message =
error instanceof Error
? error.message
: "Failed to load story TODOs.";
setTodoError(message);
setStoryTodos([]);
})
.finally(() => {
if (active) {
setIsTodoLoading(false);
}
});
return () => {
active = false;
};
}, []);
const refreshTodos = async () => {
setIsTodoLoading(true);
setTodoError(null);
try {
const response = await workflowApi.getStoryTodos();
setStoryTodos(
response.stories.map((s) => ({
storyId: s.story_id,
storyName: s.story_name,
items: s.todos,
error: s.error ?? null,
})),
);
setLastTodoRefresh(new Date());
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load story TODOs.";
setTodoError(message);
setStoryTodos([]);
} finally {
setIsTodoLoading(false);
}
};
const refreshGateState = async (targetStoryId: string = storyId) => {
setIsGateLoading(true);
setGateError(null);
try {
const response = await workflowApi.getAcceptance({
story_id: targetStoryId,
});
setGateState({
canAccept: response.can_accept,
reasons: response.reasons,
warning: response.warning ?? null,
summary: response.summary,
missingCategories: response.missing_categories,
coverageReport: response.coverage_report
? {
currentPercent: response.coverage_report.current_percent,
thresholdPercent: response.coverage_report.threshold_percent,
baselinePercent:
response.coverage_report.baseline_percent ?? null,
}
: null,
});
setLastGateRefresh(new Date());
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to load workflow gates.";
setGateError(message);
setGateState(null);
} finally {
setIsGateLoading(false);
}
};
const handleCollectCoverage = async () => {
setIsCollectingCoverage(true);
setCoverageError(null);
try {
await workflowApi.collectCoverage({ story_id: storyId });
await refreshGateState(storyId);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to collect coverage.";
setCoverageError(message);
} finally {
setIsCollectingCoverage(false);
}
};
useEffect(() => {
let active = true;
setIsUpcomingLoading(true);
setUpcomingError(null);
workflowApi
.getUpcomingStories()
.then((response) => {
if (!active) return;
setUpcomingStories(response.stories);
setLastUpcomingRefresh(new Date());
})
.catch((error) => {
if (!active) return;
const message =
error instanceof Error
? error.message
: "Failed to load upcoming stories.";
setUpcomingError(message);
setUpcomingStories([]);
})
.finally(() => {
if (active) {
setIsUpcomingLoading(false);
}
});
return () => {
active = false;
};
}, []);
const refreshUpcomingStories = async () => {
setIsUpcomingLoading(true);
setUpcomingError(null);
try {
const response = await workflowApi.getUpcomingStories();
setUpcomingStories(response.stories);
setLastUpcomingRefresh(new Date());
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to load upcoming stories.";
setUpcomingError(message);
setUpcomingStories([]);
} finally {
setIsUpcomingLoading(false);
}
};
const refreshReviewQueue = async () => {
setIsReviewLoading(true);
setReviewError(null);
try {
const response = await workflowApi.getReviewQueueAll();
setReviewQueue(response.stories);
setLastReviewRefresh(new Date());
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load review queue.";
setReviewError(message);
setReviewQueue([]);
} finally {
setIsReviewLoading(false);
}
};
const handleProceed = async (storyIdToProceed: string) => {
setProceedingStoryId(storyIdToProceed);
setProceedError(null);
setProceedSuccess(null);
try {
await workflowApi.ensureAcceptance({
story_id: storyIdToProceed,
});
setProceedSuccess(`Proceeding with ${storyIdToProceed}.`);
await refreshReviewQueue();
if (storyIdToProceed === storyId) {
await refreshGateState(storyId);
}
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to proceed with review.";
setProceedError(message);
} finally {
setProceedingStoryId(null);
}
};
useEffect(() => {
const ws = new ChatWebSocket();
wsRef.current = ws;
@@ -508,6 +162,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
console.error("WebSocket error:", message);
setLoading(false);
},
onPipelineState: (state) => {
setPipeline(state);
},
});
return () => {
@@ -1056,50 +713,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
gap: "12px",
}}
>
<AgentPanel stories={upcomingStories} />
<AgentPanel />
<ReviewPanel
reviewQueue={reviewQueue}
isReviewLoading={isReviewLoading}
reviewError={reviewError}
proceedingStoryId={proceedingStoryId}
storyId={storyId}
isGateLoading={isGateLoading}
proceedError={proceedError}
proceedSuccess={proceedSuccess}
lastReviewRefresh={lastReviewRefresh}
onRefresh={refreshReviewQueue}
onProceed={handleProceed}
/>
<StagePanel title="To Merge" items={pipeline.merge} />
<StagePanel title="QA" items={pipeline.qa} />
<StagePanel title="Current" items={pipeline.current} />
<StagePanel title="Upcoming" items={pipeline.upcoming} />
<GatePanel
gateState={gateState}
gateStatusLabel={gateStatusLabel}
gateStatusColor={gateStatusColor}
isGateLoading={isGateLoading}
gateError={gateError}
coverageError={coverageError}
lastGateRefresh={lastGateRefresh}
onRefresh={() => refreshGateState(storyId)}
onCollectCoverage={handleCollectCoverage}
isCollectingCoverage={isCollectingCoverage}
/>
<TodoPanel
todos={storyTodos}
isTodoLoading={isTodoLoading}
todoError={todoError}
lastTodoRefresh={lastTodoRefresh}
onRefresh={refreshTodos}
/>
<UpcomingPanel
stories={upcomingStories}
isLoading={isUpcomingLoading}
error={upcomingError}
lastRefresh={lastUpcomingRefresh}
onRefresh={refreshUpcomingStories}
/>
</div>
</div>