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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user