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:
@@ -8,11 +8,25 @@ export type WsRequest =
|
||||
type: "cancel";
|
||||
};
|
||||
|
||||
export interface PipelineStageItem {
|
||||
story_id: string;
|
||||
name: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface PipelineState {
|
||||
upcoming: PipelineStageItem[];
|
||||
current: PipelineStageItem[];
|
||||
qa: PipelineStageItem[];
|
||||
merge: PipelineStageItem[];
|
||||
}
|
||||
|
||||
export type WsResponse =
|
||||
| { type: "token"; content: string }
|
||||
| { type: "update"; messages: Message[] }
|
||||
| { type: "session_id"; session_id: string }
|
||||
| { type: "error"; message: string };
|
||||
| { type: "error"; message: string }
|
||||
| { type: "pipeline_state"; upcoming: PipelineStageItem[]; current: PipelineStageItem[]; qa: PipelineStageItem[]; merge: PipelineStageItem[] };
|
||||
|
||||
export interface ProviderConfig {
|
||||
provider: string;
|
||||
@@ -216,6 +230,7 @@ export class ChatWebSocket {
|
||||
private onUpdate?: (messages: Message[]) => void;
|
||||
private onSessionId?: (sessionId: string) => void;
|
||||
private onError?: (message: string) => void;
|
||||
private onPipelineState?: (state: PipelineState) => void;
|
||||
private connected = false;
|
||||
private closeTimer?: number;
|
||||
|
||||
@@ -225,6 +240,7 @@ export class ChatWebSocket {
|
||||
onUpdate?: (messages: Message[]) => void;
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
onPipelineState?: (state: PipelineState) => void;
|
||||
},
|
||||
wsPath = DEFAULT_WS_PATH,
|
||||
) {
|
||||
@@ -232,6 +248,7 @@ export class ChatWebSocket {
|
||||
this.onUpdate = handlers.onUpdate;
|
||||
this.onSessionId = handlers.onSessionId;
|
||||
this.onError = handlers.onError;
|
||||
this.onPipelineState = handlers.onPipelineState;
|
||||
|
||||
if (this.connected) {
|
||||
return;
|
||||
@@ -263,6 +280,7 @@ export class ChatWebSocket {
|
||||
if (data.type === "update") this.onUpdate?.(data.messages);
|
||||
if (data.type === "session_id") this.onSessionId?.(data.session_id);
|
||||
if (data.type === "error") this.onError?.(data.message);
|
||||
if (data.type === "pipeline_state") this.onPipelineState?.({ upcoming: data.upcoming, current: data.current, qa: data.qa, merge: data.merge });
|
||||
} catch (err) {
|
||||
this.onError?.(String(err));
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { workflowApi } from "./workflow";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function okResponse(body: unknown) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(status: number, text: string) {
|
||||
return new Response(text, { status });
|
||||
}
|
||||
|
||||
describe("workflowApi", () => {
|
||||
describe("recordTests", () => {
|
||||
it("sends POST to /workflow/tests/record", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(true));
|
||||
|
||||
const payload = {
|
||||
story_id: "story-29",
|
||||
unit: [{ name: "t1", status: "pass" as const }],
|
||||
integration: [],
|
||||
};
|
||||
|
||||
await workflowApi.recordTests(payload);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/workflow/tests/record",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAcceptance", () => {
|
||||
it("sends POST and returns acceptance response", async () => {
|
||||
const response = {
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 2, passed: 2, failed: 0 },
|
||||
missing_categories: [],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(okResponse(response));
|
||||
|
||||
const result = await workflowApi.getAcceptance({
|
||||
story_id: "story-29",
|
||||
});
|
||||
|
||||
expect(result.can_accept).toBe(true);
|
||||
expect(result.summary.total).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReviewQueueAll", () => {
|
||||
it("sends GET to /workflow/review/all", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse({ stories: [] }));
|
||||
|
||||
const result = await workflowApi.getReviewQueueAll();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/workflow/review/all",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(result.stories).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureAcceptance", () => {
|
||||
it("returns true when acceptance passes", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(true));
|
||||
|
||||
const result = await workflowApi.ensureAcceptance({
|
||||
story_id: "story-29",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
errorResponse(400, "Acceptance is blocked"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
workflowApi.ensureAcceptance({ story_id: "story-29" }),
|
||||
).rejects.toThrow("Acceptance is blocked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUpcomingStories", () => {
|
||||
it("sends GET to /workflow/upcoming", async () => {
|
||||
const response = {
|
||||
stories: [
|
||||
{ story_id: "31_view_upcoming", name: "View Upcoming" },
|
||||
{ story_id: "32_worktree", name: null },
|
||||
],
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(okResponse(response));
|
||||
|
||||
const result = await workflowApi.getUpcomingStories();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/workflow/upcoming",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(result.stories).toHaveLength(2);
|
||||
expect(result.stories[0].name).toBe("View Upcoming");
|
||||
expect(result.stories[1].name).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReviewQueue", () => {
|
||||
it("sends GET to /workflow/review", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
okResponse({ stories: [{ story_id: "s1", can_accept: true }] }),
|
||||
);
|
||||
|
||||
const result = await workflowApi.getReviewQueue();
|
||||
|
||||
expect(result.stories).toHaveLength(1);
|
||||
expect(result.stories[0].story_id).toBe("s1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
export type TestStatus = "pass" | "fail";
|
||||
|
||||
export interface TestCasePayload {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
details?: string | null;
|
||||
}
|
||||
|
||||
export interface RecordTestsPayload {
|
||||
story_id: string;
|
||||
unit: TestCasePayload[];
|
||||
integration: TestCasePayload[];
|
||||
}
|
||||
|
||||
export interface AcceptanceRequest {
|
||||
story_id: string;
|
||||
}
|
||||
|
||||
export interface TestRunSummaryResponse {
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface CoverageReportResponse {
|
||||
current_percent: number;
|
||||
threshold_percent: number;
|
||||
baseline_percent?: number | null;
|
||||
}
|
||||
|
||||
export interface AcceptanceResponse {
|
||||
can_accept: boolean;
|
||||
reasons: string[];
|
||||
warning?: string | null;
|
||||
summary: TestRunSummaryResponse;
|
||||
missing_categories: string[];
|
||||
coverage_report?: CoverageReportResponse | null;
|
||||
}
|
||||
|
||||
export interface ReviewStory {
|
||||
story_id: string;
|
||||
can_accept: boolean;
|
||||
reasons: string[];
|
||||
warning?: string | null;
|
||||
summary: TestRunSummaryResponse;
|
||||
missing_categories: string[];
|
||||
coverage_report?: CoverageReportResponse | null;
|
||||
}
|
||||
|
||||
export interface RecordCoveragePayload {
|
||||
story_id: string;
|
||||
current_percent: number;
|
||||
threshold_percent?: number | null;
|
||||
}
|
||||
|
||||
export interface CollectCoverageRequest {
|
||||
story_id: string;
|
||||
threshold_percent?: number | null;
|
||||
}
|
||||
|
||||
export interface ReviewListResponse {
|
||||
stories: ReviewStory[];
|
||||
}
|
||||
|
||||
export interface StoryTodosResponse {
|
||||
story_id: string;
|
||||
story_name: string | null;
|
||||
todos: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface TodoListResponse {
|
||||
stories: StoryTodosResponse[];
|
||||
}
|
||||
|
||||
export interface UpcomingStory {
|
||||
story_id: string;
|
||||
name: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface UpcomingStoriesResponse {
|
||||
stories: UpcomingStory[];
|
||||
}
|
||||
|
||||
export interface StoryValidationResult {
|
||||
story_id: string;
|
||||
valid: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ValidateStoriesResponse {
|
||||
stories: StoryValidationResult[];
|
||||
}
|
||||
|
||||
export interface CreateStoryPayload {
|
||||
name: string;
|
||||
user_story?: string | null;
|
||||
acceptance_criteria?: string[] | null;
|
||||
}
|
||||
|
||||
export interface CreateStoryResponse {
|
||||
story_id: string;
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
|
||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
baseUrl = DEFAULT_API_BASE,
|
||||
): Promise<T> {
|
||||
const res = await fetch(buildApiUrl(path, baseUrl), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const workflowApi = {
|
||||
collectCoverage(payload: CollectCoverageRequest, baseUrl?: string) {
|
||||
return requestJson<CoverageReportResponse>(
|
||||
"/workflow/coverage/collect",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
recordCoverage(payload: RecordCoveragePayload, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/workflow/coverage/record",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
recordTests(payload: RecordTestsPayload, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/workflow/tests/record",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
|
||||
return requestJson<AcceptanceResponse>(
|
||||
"/workflow/acceptance",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getReviewQueue(baseUrl?: string) {
|
||||
return requestJson<ReviewListResponse>("/workflow/review", {}, baseUrl);
|
||||
},
|
||||
getReviewQueueAll(baseUrl?: string) {
|
||||
return requestJson<ReviewListResponse>("/workflow/review/all", {}, baseUrl);
|
||||
},
|
||||
getUpcomingStories(baseUrl?: string) {
|
||||
return requestJson<UpcomingStoriesResponse>(
|
||||
"/workflow/upcoming",
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/workflow/acceptance/ensure",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getStoryTodos(baseUrl?: string) {
|
||||
return requestJson<TodoListResponse>("/workflow/todos", {}, baseUrl);
|
||||
},
|
||||
validateStories(baseUrl?: string) {
|
||||
return requestJson<ValidateStoriesResponse>(
|
||||
"/workflow/stories/validate",
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
createStory(payload: CreateStoryPayload, baseUrl?: string) {
|
||||
return requestJson<CreateStoryResponse>(
|
||||
"/workflow/stories/create",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -58,13 +58,7 @@ describe("AgentPanel diff command", () => {
|
||||
];
|
||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<AgentPanel
|
||||
stories={[
|
||||
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
render(<AgentPanel />);
|
||||
|
||||
// Expand the agent detail by clicking the expand button
|
||||
const expandButton = await screen.findByText("▶");
|
||||
@@ -99,13 +93,7 @@ describe("AgentPanel diff command", () => {
|
||||
];
|
||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<AgentPanel
|
||||
stories={[
|
||||
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
render(<AgentPanel />);
|
||||
|
||||
const expandButton = await screen.findByText("▶");
|
||||
await userEvent.click(expandButton);
|
||||
@@ -135,13 +123,7 @@ describe("AgentPanel diff command", () => {
|
||||
];
|
||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<AgentPanel
|
||||
stories={[
|
||||
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
render(<AgentPanel />);
|
||||
|
||||
const expandButton = await screen.findByText("▶");
|
||||
await userEvent.click(expandButton);
|
||||
@@ -164,13 +146,7 @@ describe("AgentPanel diff command", () => {
|
||||
];
|
||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||
|
||||
render(
|
||||
<AgentPanel
|
||||
stories={[
|
||||
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
render(<AgentPanel />);
|
||||
|
||||
const expandButton = await screen.findByText("▶");
|
||||
await userEvent.click(expandButton);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "../api/client";
|
||||
import type { ReviewStory } from "../api/workflow";
|
||||
import { workflowApi } from "../api/workflow";
|
||||
import type { Message } from "../types";
|
||||
import { Chat } from "./Chat";
|
||||
|
||||
@@ -39,21 +36,6 @@ vi.mock("../api/client", () => {
|
||||
return { api, ChatWebSocket };
|
||||
});
|
||||
|
||||
vi.mock("../api/workflow", () => {
|
||||
return {
|
||||
workflowApi: {
|
||||
getAcceptance: vi.fn(),
|
||||
getReviewQueue: vi.fn(),
|
||||
getReviewQueueAll: vi.fn(),
|
||||
ensureAcceptance: vi.fn(),
|
||||
recordCoverage: vi.fn(),
|
||||
collectCoverage: vi.fn(),
|
||||
getStoryTodos: vi.fn(),
|
||||
getUpcomingStories: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockedApi = {
|
||||
getOllamaModels: vi.mocked(api.getOllamaModels),
|
||||
getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
|
||||
@@ -64,587 +46,20 @@ const mockedApi = {
|
||||
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||||
};
|
||||
|
||||
const mockedWorkflow = {
|
||||
getAcceptance: vi.mocked(workflowApi.getAcceptance),
|
||||
getReviewQueue: vi.mocked(workflowApi.getReviewQueue),
|
||||
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
|
||||
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
|
||||
getStoryTodos: vi.mocked(workflowApi.getStoryTodos),
|
||||
getUpcomingStories: vi.mocked(workflowApi.getUpcomingStories),
|
||||
};
|
||||
|
||||
describe("Chat review panel", () => {
|
||||
beforeEach(() => {
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||||
|
||||
mockedWorkflow.getAcceptance.mockResolvedValue({
|
||||
can_accept: false,
|
||||
reasons: ["No test results recorded for the story."],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: ["unit", "integration"],
|
||||
});
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
|
||||
});
|
||||
|
||||
it("shows an empty review queue state", async () => {
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Stories Awaiting Review"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("0 ready / 0 total")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("No stories waiting for review."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const updatedLabels = await screen.findAllByText(/Updated/i);
|
||||
expect(updatedLabels.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("renders review stories and proceeds", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 3, passed: 3, failed: 0 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll
|
||||
.mockResolvedValueOnce({ stories: [story] })
|
||||
.mockResolvedValueOnce({ stories: [] });
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
|
||||
|
||||
const proceedButton = screen.getByRole("button", { name: "Proceed" });
|
||||
await userEvent.click(proceedButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
|
||||
story_id: story.story_id,
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("No stories waiting for review."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a review error when the queue fails to load", async () => {
|
||||
mockedWorkflow.getReviewQueueAll.mockRejectedValueOnce(
|
||||
new Error("Review queue failed"),
|
||||
);
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(/Review queue failed/i)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/Use Refresh to try again\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Retry" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes the review queue when clicking refresh", async () => {
|
||||
mockedWorkflow.getReviewQueueAll
|
||||
.mockResolvedValueOnce({ stories: [] })
|
||||
.mockResolvedValueOnce({ stories: [] });
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const refreshButtons = await screen.findAllByRole("button", {
|
||||
name: "Refresh",
|
||||
});
|
||||
const refreshButton = refreshButtons[0];
|
||||
|
||||
await userEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedWorkflow.getReviewQueueAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables proceed when a story is blocked", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: false,
|
||||
reasons: ["Missing unit tests"],
|
||||
warning: null,
|
||||
summary: { total: 1, passed: 0, failed: 1 },
|
||||
missing_categories: ["unit"],
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||
stories: [story],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(story.story_id)).toBeInTheDocument();
|
||||
|
||||
const blockedButton = screen.getByRole("button", { name: "Blocked" });
|
||||
expect(blockedButton).toBeDisabled();
|
||||
|
||||
expect(await screen.findByText("Missing: unit")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Missing unit tests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows gate panel blocked status with reasons (AC1/AC3)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: false,
|
||||
reasons: ["No approved test plan for the story."],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: ["unit", "integration"],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Blocked")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("No approved test plan for the story."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Missing: unit, integration"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/0\/0 passing, 0 failing/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows gate panel ready status when all tests pass (AC1/AC3)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missing_categories: [],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/5\/5 passing, 0 failing/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows failing badge and count in review panel (AC4/AC5)", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: false,
|
||||
reasons: ["3 tests are failing."],
|
||||
warning: "Multiple tests failing — fix one at a time.",
|
||||
summary: { total: 5, passed: 2, failed: 3 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||
stories: [story],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Failing 3")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Warning")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Multiple tests failing — fix one at a time."),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("3 tests are failing.")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/2\/5 passing, 3 failing/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const blockedButton = screen.getByRole("button", { name: "Blocked" });
|
||||
expect(blockedButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows gate warning when multiple tests fail (AC5)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: false,
|
||||
reasons: ["2 tests are failing."],
|
||||
warning: "Multiple tests failing — fix one at a time.",
|
||||
summary: { total: 4, passed: 2, failed: 2 },
|
||||
missing_categories: [],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Blocked")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Multiple tests failing — fix one at a time."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/2\/4 passing, 2 failing/),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("2 tests are failing.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not call ensureAcceptance when clicking a blocked proceed button (AC4)", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: false,
|
||||
reasons: ["Tests are failing."],
|
||||
warning: null,
|
||||
summary: { total: 3, passed: 1, failed: 2 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||
stories: [story],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const blockedButton = await screen.findByRole("button", {
|
||||
name: "Blocked",
|
||||
});
|
||||
expect(blockedButton).toBeDisabled();
|
||||
|
||||
// Clear any prior calls then attempt click on disabled button
|
||||
mockedWorkflow.ensureAcceptance.mockClear();
|
||||
await userEvent.click(blockedButton);
|
||||
|
||||
expect(mockedWorkflow.ensureAcceptance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows proceed error when ensureAcceptance fails", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 3, passed: 3, failed: 0 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||
stories: [story],
|
||||
});
|
||||
mockedWorkflow.ensureAcceptance.mockRejectedValueOnce(
|
||||
new Error("Acceptance blocked: tests still failing"),
|
||||
);
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const proceedButton = await screen.findByRole("button", {
|
||||
name: "Proceed",
|
||||
});
|
||||
await userEvent.click(proceedButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Acceptance blocked: tests still failing"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows gate error when acceptance endpoint fails", async () => {
|
||||
mockedWorkflow.getAcceptance.mockRejectedValueOnce(
|
||||
new Error("Server unreachable"),
|
||||
);
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Server unreachable")).toBeInTheDocument();
|
||||
|
||||
const retryButtons = await screen.findAllByRole("button", {
|
||||
name: "Retry",
|
||||
});
|
||||
expect(retryButtons.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("refreshes gate status after proceeding on the current story", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 2, passed: 2, failed: 0 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
mockedWorkflow.getAcceptance
|
||||
.mockResolvedValueOnce({
|
||||
can_accept: false,
|
||||
reasons: ["No test results recorded for the story."],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: ["unit", "integration"],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 2, passed: 2, failed: 0 },
|
||||
missing_categories: [],
|
||||
});
|
||||
|
||||
mockedWorkflow.getReviewQueueAll
|
||||
.mockResolvedValueOnce({ stories: [story] })
|
||||
.mockResolvedValueOnce({ stories: [] });
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const proceedButton = await screen.findByRole("button", {
|
||||
name: "Proceed",
|
||||
});
|
||||
await userEvent.click(proceedButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({
|
||||
story_id: story.story_id,
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows coverage below threshold in gate panel (AC3)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: false,
|
||||
reasons: ["Coverage below threshold (55.0% < 80.0%)."],
|
||||
warning: null,
|
||||
summary: { total: 3, passed: 3, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 55.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Blocked")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Coverage: 55\.0%/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/threshold: 80\.0%/)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Coverage below threshold (55.0% < 80.0%)."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows coverage regression in review panel (AC4)", async () => {
|
||||
const story: ReviewStory = {
|
||||
story_id: "27_protect_tests_and_coverage",
|
||||
can_accept: false,
|
||||
reasons: ["Coverage regression: 90.0% → 82.0% (threshold: 80.0%)."],
|
||||
warning: null,
|
||||
summary: { total: 4, passed: 4, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 82.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: 90.0,
|
||||
},
|
||||
};
|
||||
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({
|
||||
stories: [story],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Coverage regression: 90.0% → 82.0% (threshold: 80.0%).",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Coverage: 82\.0%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows green coverage when above threshold (AC3)", async () => {
|
||||
mockedWorkflow.getAcceptance.mockResolvedValueOnce({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 92.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: 90.0,
|
||||
},
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Ready to accept")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Coverage: 92\.0%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches upcoming stories on mount and renders panel", async () => {
|
||||
mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({
|
||||
stories: [
|
||||
{
|
||||
story_id: "31_view_upcoming",
|
||||
name: "View Upcoming Stories",
|
||||
error: null,
|
||||
},
|
||||
{ story_id: "32_worktree", name: null, error: null },
|
||||
],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Upcoming Stories")).toBeInTheDocument();
|
||||
// Both AgentPanel and ReviewPanel display story names, so multiple elements are expected
|
||||
const storyNameElements = await screen.findAllByText(
|
||||
"View Upcoming Stories",
|
||||
);
|
||||
expect(storyNameElements.length).toBeGreaterThan(0);
|
||||
const worktreeElements = await screen.findAllByText("32_worktree");
|
||||
expect(worktreeElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("collect coverage button triggers collection and refreshes gate", async () => {
|
||||
const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage);
|
||||
mockedCollectCoverage.mockResolvedValueOnce({
|
||||
current_percent: 85.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: null,
|
||||
});
|
||||
|
||||
mockedWorkflow.getAcceptance
|
||||
.mockResolvedValueOnce({
|
||||
can_accept: false,
|
||||
reasons: ["No test results recorded for the story."],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: ["unit", "integration"],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missing_categories: [],
|
||||
coverage_report: {
|
||||
current_percent: 85.0,
|
||||
threshold_percent: 80.0,
|
||||
baseline_percent: null,
|
||||
},
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const collectButton = await screen.findByRole("button", {
|
||||
name: "Collect Coverage",
|
||||
});
|
||||
await userEvent.click(collectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedCollectCoverage).toHaveBeenCalledWith({
|
||||
story_id: "26_establish_tdd_workflow_and_gates",
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Coverage: 85\.0%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows story TODOs when unchecked criteria exist", async () => {
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValueOnce({
|
||||
stories: [
|
||||
{
|
||||
story_id: "28_ui_show_test_todos",
|
||||
story_name: "Show Remaining Test TODOs in the UI",
|
||||
todos: [
|
||||
"The UI lists unchecked acceptance criteria.",
|
||||
"Each TODO is displayed as its full text.",
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("The UI lists unchecked acceptance criteria."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("Each TODO is displayed as its full text."),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("2 remaining")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows completion message when all criteria are checked", async () => {
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValueOnce({
|
||||
stories: [
|
||||
{
|
||||
story_id: "28_ui_show_test_todos",
|
||||
story_name: "Show Remaining Test TODOs in the UI",
|
||||
todos: [],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText("All acceptance criteria complete."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows TODO error when endpoint fails", async () => {
|
||||
mockedWorkflow.getStoryTodos.mockRejectedValueOnce(
|
||||
new Error("Cannot read stories"),
|
||||
);
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Cannot read stories")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not fetch Anthropic models when no API key exists", async () => {
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
||||
mockedApi.getAnthropicModels.mockClear();
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.getAnthropicApiKeyExists).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
function setupMocks() {
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||||
}
|
||||
|
||||
describe("Chat message rendering — unified tool call UI", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
|
||||
mockedWorkflow.getAcceptance.mockResolvedValue({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: [],
|
||||
});
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("renders tool call badge for assistant message with tool_calls (AC3)", async () => {
|
||||
@@ -675,7 +90,6 @@ describe("Chat message rendering — unified tool call UI", () => {
|
||||
});
|
||||
|
||||
expect(await screen.findByText("I'll read that file.")).toBeInTheDocument();
|
||||
// Tool call badge should appear showing the function name and first arg
|
||||
expect(await screen.findByText("Read(src/main.rs)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -709,7 +123,6 @@ describe("Chat message rendering — unified tool call UI", () => {
|
||||
capturedWsHandlers?.onUpdate(messages);
|
||||
});
|
||||
|
||||
// Tool output section should be collapsible
|
||||
expect(await screen.findByText(/Tool Output/)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("The file contains a main function."),
|
||||
@@ -733,7 +146,6 @@ describe("Chat message rendering — unified tool call UI", () => {
|
||||
expect(
|
||||
await screen.findByText("Hi there! How can I help?"),
|
||||
).toBeInTheDocument();
|
||||
// No tool call badges should appear
|
||||
expect(screen.queryByText(/Tool Output/)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -769,30 +181,25 @@ describe("Chat message rendering — unified tool call UI", () => {
|
||||
expect(await screen.findByText("Bash(cargo test)")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Read(Cargo.toml)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not fetch Anthropic models when no API key exists", async () => {
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
||||
mockedApi.getAnthropicModels.mockClear();
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.getAnthropicApiKeyExists).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockedApi.getAnthropicModels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chat two-column layout", () => {
|
||||
beforeEach(() => {
|
||||
capturedWsHandlers = null;
|
||||
|
||||
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.cancelChat.mockResolvedValue(true);
|
||||
|
||||
mockedWorkflow.getAcceptance.mockResolvedValue({
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missing_categories: [],
|
||||
});
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it("renders left and right column containers (AC1, AC2)", async () => {
|
||||
@@ -812,13 +219,11 @@ describe("Chat two-column layout", () => {
|
||||
});
|
||||
|
||||
it("renders panels inside the right column (AC2)", async () => {
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||
|
||||
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
|
||||
const rightColumn = await screen.findByTestId("chat-right-column");
|
||||
const reviewPanel = await screen.findByText("Stories Awaiting Review");
|
||||
expect(rightColumn).toContainElement(reviewPanel);
|
||||
const agentsPanel = await screen.findByText("Agents");
|
||||
expect(rightColumn).toContainElement(agentsPanel);
|
||||
});
|
||||
|
||||
it("uses row flex-direction on wide screens (AC3)", async () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GatePanel } from "./GatePanel";
|
||||
|
||||
const baseProps = {
|
||||
gateState: null,
|
||||
gateStatusLabel: "Unknown",
|
||||
gateStatusColor: "#aaa",
|
||||
isGateLoading: false,
|
||||
gateError: null,
|
||||
coverageError: null,
|
||||
lastGateRefresh: null,
|
||||
onRefresh: vi.fn(),
|
||||
onCollectCoverage: vi.fn(),
|
||||
isCollectingCoverage: false,
|
||||
};
|
||||
|
||||
describe("GatePanel", () => {
|
||||
it("shows 'no workflow data' when gateState is null", () => {
|
||||
render(<GatePanel {...baseProps} />);
|
||||
expect(screen.getByText("No workflow data yet.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading message when isGateLoading is true", () => {
|
||||
render(<GatePanel {...baseProps} isGateLoading={true} />);
|
||||
expect(screen.getByText("Loading workflow gates...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error with retry button", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateError="Connection failed"
|
||||
onRefresh={onRefresh}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Connection failed")).toBeInTheDocument();
|
||||
|
||||
const retryButton = screen.getByRole("button", { name: "Retry" });
|
||||
await userEvent.click(retryButton);
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows gate status label and color", () => {
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateStatusLabel="Blocked"
|
||||
gateStatusColor="#ff7b72"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Blocked")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows test summary when gateState is provided", () => {
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateState={{
|
||||
canAccept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missingCategories: [],
|
||||
coverageReport: null,
|
||||
}}
|
||||
gateStatusLabel="Ready to accept"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/5\/5 passing, 0 failing/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows missing categories", () => {
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateState={{
|
||||
canAccept: false,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 0, passed: 0, failed: 0 },
|
||||
missingCategories: ["unit", "integration"],
|
||||
coverageReport: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows warning text", () => {
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateState={{
|
||||
canAccept: false,
|
||||
reasons: [],
|
||||
warning: "Multiple tests failing — fix one at a time.",
|
||||
summary: { total: 4, passed: 2, failed: 2 },
|
||||
missingCategories: [],
|
||||
coverageReport: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Multiple tests failing — fix one at a time."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows reasons as list items", () => {
|
||||
render(
|
||||
<GatePanel
|
||||
{...baseProps}
|
||||
gateState={{
|
||||
canAccept: false,
|
||||
reasons: ["No approved test plan.", "Tests are failing."],
|
||||
warning: null,
|
||||
summary: { total: 2, passed: 1, failed: 1 },
|
||||
missingCategories: [],
|
||||
coverageReport: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("No approved test plan.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tests are failing.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRefresh when Refresh button is clicked", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(<GatePanel {...baseProps} onRefresh={onRefresh} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disables Refresh button when loading", () => {
|
||||
render(<GatePanel {...baseProps} isGateLoading={true} />);
|
||||
expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,237 +0,0 @@
|
||||
interface CoverageReport {
|
||||
currentPercent: number;
|
||||
thresholdPercent: number;
|
||||
baselinePercent: number | null;
|
||||
}
|
||||
|
||||
interface GateState {
|
||||
canAccept: boolean;
|
||||
reasons: string[];
|
||||
warning: string | null;
|
||||
summary: {
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
};
|
||||
missingCategories: string[];
|
||||
coverageReport: CoverageReport | null;
|
||||
}
|
||||
|
||||
interface GatePanelProps {
|
||||
gateState: GateState | null;
|
||||
gateStatusLabel: string;
|
||||
gateStatusColor: string;
|
||||
isGateLoading: boolean;
|
||||
gateError: string | null;
|
||||
coverageError: string | null;
|
||||
lastGateRefresh: Date | null;
|
||||
onRefresh: () => void;
|
||||
onCollectCoverage: () => void;
|
||||
isCollectingCoverage: boolean;
|
||||
}
|
||||
|
||||
const formatTimestamp = (value: Date | null): string => {
|
||||
if (!value) return "—";
|
||||
return value.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
export function GatePanel({
|
||||
gateState,
|
||||
gateStatusLabel,
|
||||
gateStatusColor,
|
||||
isGateLoading,
|
||||
gateError,
|
||||
coverageError,
|
||||
lastGateRefresh,
|
||||
onRefresh,
|
||||
onCollectCoverage,
|
||||
isCollectingCoverage,
|
||||
}: GatePanelProps) {
|
||||
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 }}>Workflow Gates</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isGateLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background: isGateLoading ? "#2a2a2a" : "#2f2f2f",
|
||||
color: isGateLoading ? "#777" : "#aaa",
|
||||
cursor: isGateLoading ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollectCoverage}
|
||||
disabled={isCollectingCoverage || isGateLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background:
|
||||
isCollectingCoverage || isGateLoading ? "#2a2a2a" : "#2f2f2f",
|
||||
color: isCollectingCoverage || isGateLoading ? "#777" : "#aaa",
|
||||
cursor:
|
||||
isCollectingCoverage || isGateLoading
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isCollectingCoverage ? "Collecting..." : "Collect Coverage"}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
gap: "2px",
|
||||
fontSize: "0.85em",
|
||||
color: gateStatusColor,
|
||||
}}
|
||||
>
|
||||
<div>{gateStatusLabel}</div>
|
||||
<div style={{ fontSize: "0.8em", color: "#777" }}>
|
||||
Updated {formatTimestamp(lastGateRefresh)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isGateLoading ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
Loading workflow gates...
|
||||
</div>
|
||||
) : gateError ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: "#ff7b72",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span>{gateError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isGateLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background: isGateLoading ? "#2a2a2a" : "#2f2f2f",
|
||||
color: isGateLoading ? "#777" : "#aaa",
|
||||
cursor: isGateLoading ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : gateState ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
Summary: {gateState.summary.passed}/{gateState.summary.total}{" "}
|
||||
passing, {gateState.summary.failed} failing
|
||||
</div>
|
||||
{gateState.coverageReport && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color:
|
||||
gateState.coverageReport.currentPercent <
|
||||
gateState.coverageReport.thresholdPercent
|
||||
? "#ff7b72"
|
||||
: "#7ee787",
|
||||
}}
|
||||
>
|
||||
Coverage: {gateState.coverageReport.currentPercent.toFixed(1)}%
|
||||
(threshold: {gateState.coverageReport.thresholdPercent.toFixed(1)}
|
||||
%)
|
||||
</div>
|
||||
)}
|
||||
{coverageError && (
|
||||
<div style={{ fontSize: "0.85em", color: "#ff7b72" }}>
|
||||
Coverage error: {coverageError}
|
||||
</div>
|
||||
)}
|
||||
{gateState.missingCategories.length > 0 && (
|
||||
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
|
||||
Missing: {gateState.missingCategories.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{gateState.warning && (
|
||||
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
|
||||
{gateState.warning}
|
||||
</div>
|
||||
)}
|
||||
{gateState.reasons.length > 0 && (
|
||||
<ul
|
||||
style={{
|
||||
margin: "0 0 0 16px",
|
||||
padding: 0,
|
||||
fontSize: "0.85em",
|
||||
color: "#ccc",
|
||||
}}
|
||||
>
|
||||
{gateState.reasons.map((reason) => (
|
||||
<li key={`gate-reason-${reason}`}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
No workflow data yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReviewStory } from "../api/workflow";
|
||||
import { ReviewPanel } from "./ReviewPanel";
|
||||
|
||||
const readyStory: ReviewStory = {
|
||||
story_id: "29_backfill_tests",
|
||||
can_accept: true,
|
||||
reasons: [],
|
||||
warning: null,
|
||||
summary: { total: 5, passed: 5, failed: 0 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
const blockedStory: ReviewStory = {
|
||||
story_id: "26_tdd_gates",
|
||||
can_accept: false,
|
||||
reasons: ["2 tests are failing."],
|
||||
warning: "Multiple tests failing — fix one at a time.",
|
||||
summary: { total: 5, passed: 3, failed: 2 },
|
||||
missing_categories: [],
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
reviewQueue: [] as ReviewStory[],
|
||||
isReviewLoading: false,
|
||||
reviewError: null,
|
||||
proceedingStoryId: null,
|
||||
storyId: "",
|
||||
isGateLoading: false,
|
||||
proceedError: null,
|
||||
proceedSuccess: null,
|
||||
lastReviewRefresh: null,
|
||||
onRefresh: vi.fn(),
|
||||
onProceed: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
describe("ReviewPanel", () => {
|
||||
it("shows empty state when no stories", () => {
|
||||
render(<ReviewPanel {...baseProps} />);
|
||||
expect(
|
||||
screen.getByText("No stories waiting for review."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading state", () => {
|
||||
render(<ReviewPanel {...baseProps} isReviewLoading={true} />);
|
||||
expect(screen.getByText("Loading review queue...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error with retry button", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(
|
||||
<ReviewPanel
|
||||
{...baseProps}
|
||||
reviewError="Network error"
|
||||
onRefresh={onRefresh}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Network error.*Use Refresh to try again\./),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders ready story with Proceed button", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[readyStory]} />);
|
||||
|
||||
expect(screen.getByText("29_backfill_tests")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ready")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Proceed" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("renders blocked story with disabled button", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||
|
||||
expect(screen.getByText("26_tdd_gates")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Blocked")).toHaveLength(2);
|
||||
expect(screen.getByRole("button", { name: "Blocked" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows failing badge with count", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||
expect(screen.getByText("Failing 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows warning badge", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||
expect(screen.getByText("Warning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows test summary per story", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[readyStory]} />);
|
||||
expect(screen.getByText(/5\/5 passing,\s*0 failing/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows missing categories", () => {
|
||||
const missingStory: ReviewStory = {
|
||||
...blockedStory,
|
||||
missing_categories: ["unit", "integration"],
|
||||
};
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[missingStory]} />);
|
||||
expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onProceed when Proceed is clicked", async () => {
|
||||
const onProceed = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<ReviewPanel
|
||||
{...baseProps}
|
||||
reviewQueue={[readyStory]}
|
||||
onProceed={onProceed}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Proceed" }));
|
||||
expect(onProceed).toHaveBeenCalledWith("29_backfill_tests");
|
||||
});
|
||||
|
||||
it("shows queue counts in header", () => {
|
||||
render(
|
||||
<ReviewPanel {...baseProps} reviewQueue={[readyStory, blockedStory]} />,
|
||||
);
|
||||
expect(screen.getByText(/1 ready \/ 2 total/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows proceedError message", () => {
|
||||
render(
|
||||
<ReviewPanel
|
||||
{...baseProps}
|
||||
proceedError="Acceptance blocked: tests failing"
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Acceptance blocked: tests failing"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows proceedSuccess message", () => {
|
||||
render(
|
||||
<ReviewPanel
|
||||
{...baseProps}
|
||||
proceedSuccess="Story accepted successfully"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Story accepted successfully")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows reasons as list items", () => {
|
||||
render(<ReviewPanel {...baseProps} reviewQueue={[blockedStory]} />);
|
||||
expect(screen.getByText("2 tests are failing.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,340 +0,0 @@
|
||||
import type { ReviewStory } from "../api/workflow";
|
||||
|
||||
interface ReviewPanelProps {
|
||||
reviewQueue: ReviewStory[];
|
||||
isReviewLoading: boolean;
|
||||
reviewError: string | null;
|
||||
proceedingStoryId: string | null;
|
||||
storyId: string;
|
||||
isGateLoading: boolean;
|
||||
proceedError: string | null;
|
||||
proceedSuccess: string | null;
|
||||
lastReviewRefresh: Date | null;
|
||||
onRefresh: () => void;
|
||||
onProceed: (storyId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const formatTimestamp = (value: Date | null): string => {
|
||||
if (!value) return "—";
|
||||
return value.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
export function ReviewPanel({
|
||||
reviewQueue,
|
||||
isReviewLoading,
|
||||
reviewError,
|
||||
proceedingStoryId,
|
||||
storyId,
|
||||
isGateLoading,
|
||||
proceedError,
|
||||
proceedSuccess,
|
||||
lastReviewRefresh,
|
||||
onRefresh,
|
||||
onProceed,
|
||||
}: ReviewPanelProps) {
|
||||
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 }}>Stories Awaiting Review</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isReviewLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background: isReviewLoading ? "#2a2a2a" : "#2f2f2f",
|
||||
color: isReviewLoading ? "#777" : "#aaa",
|
||||
cursor: isReviewLoading ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
gap: "2px",
|
||||
fontSize: "0.85em",
|
||||
color: "#aaa",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{reviewQueue.filter((story) => story.can_accept).length} ready /{" "}
|
||||
{reviewQueue.length} total
|
||||
</div>
|
||||
<div style={{ fontSize: "0.8em", color: "#777" }}>
|
||||
Updated {formatTimestamp(lastReviewRefresh)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isReviewLoading ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
Loading review queue...
|
||||
</div>
|
||||
) : reviewError ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: "#ff7b72",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span>{reviewError} Use Refresh to try again.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isReviewLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background: isReviewLoading ? "#2a2a2a" : "#2f2f2f",
|
||||
color: isReviewLoading ? "#777" : "#aaa",
|
||||
cursor: isReviewLoading ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : reviewQueue.length === 0 ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
No stories waiting for review.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{reviewQueue.map((story) => (
|
||||
<div
|
||||
key={`review-${story.story_id}`}
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "10px 12px",
|
||||
background: "#191919",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{story.story_id}</div>
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: "999px",
|
||||
fontSize: "0.7em",
|
||||
fontWeight: 600,
|
||||
background: story.can_accept ? "#7ee787" : "#ff7b72",
|
||||
color: story.can_accept ? "#000" : "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{story.can_accept ? "Ready" : "Blocked"}
|
||||
</span>
|
||||
{story.summary.failed > 0 && (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: "999px",
|
||||
fontSize: "0.7em",
|
||||
fontWeight: 600,
|
||||
background: "#ffb86c",
|
||||
color: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
Failing {story.summary.failed}
|
||||
</span>
|
||||
)}
|
||||
{story.warning && (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: "999px",
|
||||
fontSize: "0.7em",
|
||||
fontWeight: 600,
|
||||
background: "#ffb86c",
|
||||
color: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
Warning
|
||||
</span>
|
||||
)}
|
||||
{story.missing_categories.length > 0 && (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: "999px",
|
||||
fontSize: "0.7em",
|
||||
fontWeight: 600,
|
||||
background: "#3a2a1a",
|
||||
color: "#ffb86c",
|
||||
border: "1px solid #5a3a1a",
|
||||
}}
|
||||
>
|
||||
Missing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
proceedingStoryId === story.story_id ||
|
||||
isReviewLoading ||
|
||||
(story.story_id === storyId && isGateLoading) ||
|
||||
!story.can_accept
|
||||
}
|
||||
onClick={() => onProceed(story.story_id)}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
background:
|
||||
proceedingStoryId === story.story_id
|
||||
? "#444"
|
||||
: story.can_accept
|
||||
? "#7ee787"
|
||||
: "#333",
|
||||
color:
|
||||
proceedingStoryId === story.story_id
|
||||
? "#bbb"
|
||||
: story.can_accept
|
||||
? "#000"
|
||||
: "#aaa",
|
||||
cursor:
|
||||
proceedingStoryId === story.story_id || !story.can_accept
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
fontSize: "0.85em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{proceedingStoryId === story.story_id
|
||||
? "Proceeding..."
|
||||
: story.can_accept
|
||||
? "Proceed"
|
||||
: "Blocked"}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
Summary: {story.summary.passed}/{story.summary.total} passing,{" "}
|
||||
{` ${story.summary.failed}`} failing
|
||||
</div>
|
||||
{story.coverage_report && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color:
|
||||
story.coverage_report.current_percent <
|
||||
story.coverage_report.threshold_percent
|
||||
? "#ff7b72"
|
||||
: "#7ee787",
|
||||
}}
|
||||
>
|
||||
Coverage: {story.coverage_report.current_percent.toFixed(1)}%
|
||||
(threshold:{" "}
|
||||
{story.coverage_report.threshold_percent.toFixed(1)}%)
|
||||
</div>
|
||||
)}
|
||||
{story.missing_categories.length > 0 && (
|
||||
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
|
||||
Missing: {story.missing_categories.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{story.reasons.length > 0 && (
|
||||
<ul
|
||||
style={{
|
||||
margin: "0 0 0 16px",
|
||||
padding: 0,
|
||||
fontSize: "0.85em",
|
||||
color: "#ccc",
|
||||
}}
|
||||
>
|
||||
{story.reasons.map((reason) => (
|
||||
<li key={`review-reason-${story.story_id}-${reason}`}>
|
||||
{reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{story.warning && (
|
||||
<div style={{ fontSize: "0.85em", color: "#ffb86c" }}>
|
||||
{story.warning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proceedError && (
|
||||
<div style={{ fontSize: "0.85em", color: "#ff7b72" }}>
|
||||
{proceedError}
|
||||
</div>
|
||||
)}
|
||||
{proceedSuccess && (
|
||||
<div style={{ fontSize: "0.85em", color: "#7ee787" }}>
|
||||
{proceedSuccess}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/StagePanel.tsx
Normal file
106
frontend/src/components/StagePanel.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { PipelineStageItem } from "../api/client";
|
||||
|
||||
interface StagePanelProps {
|
||||
title: string;
|
||||
items: PipelineStageItem[];
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function StagePanel({
|
||||
title,
|
||||
items,
|
||||
emptyMessage = "Empty.",
|
||||
}: StagePanelProps) {
|
||||
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={{ fontWeight: 600 }}>{title}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: "#aaa",
|
||||
}}
|
||||
>
|
||||
{items.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#555" }}>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const itemNumber = item.story_id.match(/^(\d+)/)?.[1];
|
||||
return (
|
||||
<div
|
||||
key={`${title}-${item.story_id}`}
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
background: "#191919",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
|
||||
{itemNumber && (
|
||||
<span
|
||||
style={{
|
||||
color: "#777",
|
||||
fontFamily: "monospace",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
>
|
||||
#{itemNumber}
|
||||
</span>
|
||||
)}
|
||||
{item.name ?? item.story_id}
|
||||
</div>
|
||||
{item.error && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
color: "#ff7b72",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{item.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TodoPanel } from "./TodoPanel";
|
||||
|
||||
const baseProps = {
|
||||
todos: [] as {
|
||||
storyId: string;
|
||||
storyName: string | null;
|
||||
items: string[];
|
||||
error: string | null;
|
||||
}[],
|
||||
isTodoLoading: false,
|
||||
todoError: null,
|
||||
lastTodoRefresh: null,
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
|
||||
describe("TodoPanel", () => {
|
||||
it("shows per-story front matter error", () => {
|
||||
render(
|
||||
<TodoPanel
|
||||
{...baseProps}
|
||||
todos={[
|
||||
{
|
||||
storyId: "28_todos",
|
||||
storyName: null,
|
||||
items: [],
|
||||
error: "Missing front matter",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Missing front matter")).toBeInTheDocument();
|
||||
expect(screen.getByText("28_todos")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error alongside todo items", () => {
|
||||
render(
|
||||
<TodoPanel
|
||||
{...baseProps}
|
||||
todos={[
|
||||
{
|
||||
storyId: "28_todos",
|
||||
storyName: "Show TODOs",
|
||||
items: ["First criterion"],
|
||||
error: "Missing 'test_plan' field",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Missing 'test_plan' field")).toBeInTheDocument();
|
||||
expect(screen.getByText("First criterion")).toBeInTheDocument();
|
||||
expect(screen.getByText("Show TODOs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show error when null", () => {
|
||||
render(
|
||||
<TodoPanel
|
||||
{...baseProps}
|
||||
todos={[
|
||||
{
|
||||
storyId: "28_todos",
|
||||
storyName: "Show TODOs",
|
||||
items: ["A criterion"],
|
||||
error: null,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("story-error-28_todos")).toBeNull();
|
||||
expect(screen.getByText("A criterion")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
interface StoryTodos {
|
||||
storyId: string;
|
||||
storyName: string | null;
|
||||
items: string[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface TodoPanelProps {
|
||||
todos: StoryTodos[];
|
||||
isTodoLoading: boolean;
|
||||
todoError: string | null;
|
||||
lastTodoRefresh: Date | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const formatTimestamp = (value: Date | null): string => {
|
||||
if (!value) return "\u2014";
|
||||
return value.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
export function TodoPanel({
|
||||
todos,
|
||||
isTodoLoading,
|
||||
todoError,
|
||||
lastTodoRefresh,
|
||||
onRefresh,
|
||||
}: TodoPanelProps) {
|
||||
const totalTodos = todos.reduce((sum, s) => sum + s.items.length, 0);
|
||||
const hasErrors = todos.some((s) => s.error);
|
||||
|
||||
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 }}>Story TODOs</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isTodoLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background: isTodoLoading ? "#2a2a2a" : "#2f2f2f",
|
||||
color: isTodoLoading ? "#777" : "#aaa",
|
||||
cursor: isTodoLoading ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
gap: "2px",
|
||||
fontSize: "0.85em",
|
||||
color: totalTodos === 0 ? "#7ee787" : "#aaa",
|
||||
}}
|
||||
>
|
||||
<div>{totalTodos} remaining</div>
|
||||
<div style={{ fontSize: "0.8em", color: "#777" }}>
|
||||
Updated {formatTimestamp(lastTodoRefresh)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTodoLoading ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
Loading story TODOs...
|
||||
</div>
|
||||
) : todoError ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: "#ff7b72",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span>{todoError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isTodoLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background: isTodoLoading ? "#2a2a2a" : "#2f2f2f",
|
||||
color: isTodoLoading ? "#777" : "#aaa",
|
||||
cursor: isTodoLoading ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : totalTodos === 0 && !hasErrors ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#7ee787" }}>
|
||||
All acceptance criteria complete.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{todos
|
||||
.filter((s) => s.items.length > 0 || s.error)
|
||||
.map((story) => (
|
||||
<div key={story.storyId}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
color: "#777",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{story.storyName ?? story.storyId}
|
||||
</div>
|
||||
{story.error && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
color: "#ff7b72",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
data-testid={`story-error-${story.storyId}`}
|
||||
>
|
||||
{story.error}
|
||||
</div>
|
||||
)}
|
||||
{story.items.length > 0 && (
|
||||
<ul
|
||||
style={{
|
||||
margin: "0 0 0 16px",
|
||||
padding: 0,
|
||||
fontSize: "0.85em",
|
||||
color: "#ccc",
|
||||
}}
|
||||
>
|
||||
{story.items.map((item) => (
|
||||
<li key={`todo-${story.storyId}-${item}`}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UpcomingStory } from "../api/workflow";
|
||||
import { UpcomingPanel } from "./UpcomingPanel";
|
||||
|
||||
const baseProps = {
|
||||
stories: [] as UpcomingStory[],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastRefresh: null,
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
|
||||
describe("UpcomingPanel", () => {
|
||||
it("shows empty state when no stories", () => {
|
||||
render(<UpcomingPanel {...baseProps} />);
|
||||
expect(screen.getByText("No upcoming stories.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading state", () => {
|
||||
render(<UpcomingPanel {...baseProps} isLoading={true} />);
|
||||
expect(screen.getByText("Loading upcoming stories...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error with retry button", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(
|
||||
<UpcomingPanel
|
||||
{...baseProps}
|
||||
error="Network error"
|
||||
onRefresh={onRefresh}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Network error.*Use Refresh to try again\./),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders story list with names", () => {
|
||||
const stories: UpcomingStory[] = [
|
||||
{
|
||||
story_id: "31_view_upcoming",
|
||||
name: "View Upcoming Stories",
|
||||
error: null,
|
||||
},
|
||||
{ story_id: "32_worktree", name: "Worktree Orchestration", error: null },
|
||||
];
|
||||
render(<UpcomingPanel {...baseProps} stories={stories} />);
|
||||
|
||||
expect(screen.getByText("View Upcoming Stories")).toBeInTheDocument();
|
||||
expect(screen.getByText("Worktree Orchestration")).toBeInTheDocument();
|
||||
expect(screen.getByText("31_view_upcoming")).toBeInTheDocument();
|
||||
expect(screen.getByText("32_worktree")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders story without name using story_id", () => {
|
||||
const stories: UpcomingStory[] = [
|
||||
{ story_id: "33_no_name", name: null, error: null },
|
||||
];
|
||||
render(<UpcomingPanel {...baseProps} stories={stories} />);
|
||||
|
||||
expect(screen.getByText("33_no_name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRefresh when Refresh clicked", async () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(<UpcomingPanel {...baseProps} onRefresh={onRefresh} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disables Refresh while loading", () => {
|
||||
render(<UpcomingPanel {...baseProps} isLoading={true} />);
|
||||
expect(screen.getByRole("button", { name: "Refresh" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { UpcomingStory } from "../api/workflow";
|
||||
|
||||
interface UpcomingPanelProps {
|
||||
stories: UpcomingStory[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastRefresh: Date | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const formatTimestamp = (value: Date | null): string => {
|
||||
if (!value) return "—";
|
||||
return value.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
export function UpcomingPanel({
|
||||
stories,
|
||||
isLoading,
|
||||
error,
|
||||
lastRefresh,
|
||||
onRefresh,
|
||||
}: UpcomingPanelProps) {
|
||||
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 }}>Upcoming Stories</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background: isLoading ? "#2a2a2a" : "#2f2f2f",
|
||||
color: isLoading ? "#777" : "#aaa",
|
||||
cursor: isLoading ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
gap: "2px",
|
||||
fontSize: "0.85em",
|
||||
color: "#aaa",
|
||||
}}
|
||||
>
|
||||
<div>{stories.length} stories</div>
|
||||
<div style={{ fontSize: "0.8em", color: "#777" }}>
|
||||
Updated {formatTimestamp(lastRefresh)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
Loading upcoming stories...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85em",
|
||||
color: "#ff7b72",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span>{error} Use Refresh to try again.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "999px",
|
||||
border: "1px solid #333",
|
||||
background: "#2f2f2f",
|
||||
color: "#aaa",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : stories.length === 0 ? (
|
||||
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
|
||||
No upcoming stories.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{stories.map((story) => (
|
||||
<div
|
||||
key={`upcoming-${story.story_id}`}
|
||||
style={{
|
||||
border: "1px solid #2a2a2a",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
background: "#191919",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
|
||||
{story.name ?? story.story_id}
|
||||
</div>
|
||||
{story.name && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
color: "#777",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{story.story_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{story.error && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
color: "#ff7b72",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{story.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user