diff --git a/.story_kit/stories/upcoming/28_ui_show_test_todos.md b/.story_kit/stories/current/28_ui_show_test_todos.md similarity index 100% rename from .story_kit/stories/upcoming/28_ui_show_test_todos.md rename to .story_kit/stories/current/28_ui_show_test_todos.md diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a949750..000e591 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -43,6 +43,9 @@ vi.mock("./api/workflow", () => { recordTests: vi.fn(), ensureAcceptance: vi.fn(), getReviewQueue: vi.fn(), + collectCoverage: vi.fn(), + recordCoverage: vi.fn(), + getStoryTodos: vi.fn().mockResolvedValue({ stories: [] }), }, }; }); diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts index e2d3f33..34359dc 100644 --- a/frontend/src/api/workflow.ts +++ b/frontend/src/api/workflow.ts @@ -62,6 +62,16 @@ export interface ReviewListResponse { stories: ReviewStory[]; } +export interface StoryTodosResponse { + story_id: string; + story_name: string | null; + todos: string[]; +} + +export interface TodoListResponse { + stories: StoryTodosResponse[]; +} + const DEFAULT_API_BASE = "/api"; function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { @@ -131,4 +141,7 @@ export const workflowApi = { baseUrl, ); }, + getStoryTodos(baseUrl?: string) { + return requestJson("/workflow/todos", {}, baseUrl); + }, }; diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index ddcb145..af15d5c 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -35,6 +35,7 @@ vi.mock("../api/workflow", () => { ensureAcceptance: vi.fn(), recordCoverage: vi.fn(), collectCoverage: vi.fn(), + getStoryTodos: vi.fn(), }, }; }); @@ -54,6 +55,7 @@ const mockedWorkflow = { getReviewQueue: vi.mocked(workflowApi.getReviewQueue), getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll), ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance), + getStoryTodos: vi.mocked(workflowApi.getStoryTodos), }; describe("Chat review panel", () => { @@ -75,6 +77,7 @@ describe("Chat review panel", () => { }); mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] }); mockedWorkflow.ensureAcceptance.mockResolvedValue(true); + mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] }); }); it("shows an empty review queue state", async () => { @@ -510,4 +513,57 @@ describe("Chat review panel", () => { 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.", + ], + }, + ], + }); + + render(); + + 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: [], + }, + ], + }); + + render(); + + 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(); + + expect(await screen.findByText("Cannot read stories")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 792bc95..2298d0d 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -9,6 +9,7 @@ import type { Message, ProviderConfig, ToolCall } from "../types"; import { ChatHeader } from "./ChatHeader"; import { GatePanel } from "./GatePanel"; import { ReviewPanel } from "./ReviewPanel"; +import { TodoPanel } from "./TodoPanel"; const { useCallback, useEffect, useRef, useState } = React; @@ -61,6 +62,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [lastGateRefresh, setLastGateRefresh] = useState(null); const [isCollectingCoverage, setIsCollectingCoverage] = useState(false); const [coverageError, setCoverageError] = useState(null); + const [storyTodos, setStoryTodos] = useState< + { storyId: string; storyName: string | null; items: string[] }[] + >([]); + const [todoError, setTodoError] = useState(null); + const [isTodoLoading, setIsTodoLoading] = useState(false); + const [lastTodoRefresh, setLastTodoRefresh] = useState(null); const storyId = "26_establish_tdd_workflow_and_gates"; const gateStatusColor = isGateLoading @@ -255,6 +262,68 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { }; }, []); + 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, + })), + ); + 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, + })), + ); + 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); @@ -599,6 +668,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { onCollectCoverage={handleCollectCoverage} isCollectingCoverage={isCollectingCoverage} /> + + diff --git a/frontend/src/components/GatePanel.test.tsx b/frontend/src/components/GatePanel.test.tsx index fa53004..ef480d6 100644 --- a/frontend/src/components/GatePanel.test.tsx +++ b/frontend/src/components/GatePanel.test.tsx @@ -9,8 +9,11 @@ const baseProps = { gateStatusColor: "#aaa", isGateLoading: false, gateError: null, + coverageError: null, lastGateRefresh: null, onRefresh: vi.fn(), + onCollectCoverage: vi.fn(), + isCollectingCoverage: false, }; describe("GatePanel", () => { @@ -21,9 +24,7 @@ describe("GatePanel", () => { it("shows loading message when isGateLoading is true", () => { render(); - expect( - screen.getByText("Loading workflow gates..."), - ).toBeInTheDocument(); + expect(screen.getByText("Loading workflow gates...")).toBeInTheDocument(); }); it("shows error with retry button", async () => { @@ -64,13 +65,12 @@ describe("GatePanel", () => { 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(); + expect(screen.getByText(/5\/5 passing, 0 failing/)).toBeInTheDocument(); }); it("shows missing categories", () => { @@ -83,12 +83,11 @@ describe("GatePanel", () => { warning: null, summary: { total: 0, passed: 0, failed: 0 }, missingCategories: ["unit", "integration"], + coverageReport: null, }} />, ); - expect( - screen.getByText("Missing: unit, integration"), - ).toBeInTheDocument(); + expect(screen.getByText("Missing: unit, integration")).toBeInTheDocument(); }); it("shows warning text", () => { @@ -101,6 +100,7 @@ describe("GatePanel", () => { warning: "Multiple tests failing — fix one at a time.", summary: { total: 4, passed: 2, failed: 2 }, missingCategories: [], + coverageReport: null, }} />, ); @@ -119,12 +119,11 @@ describe("GatePanel", () => { warning: null, summary: { total: 2, passed: 1, failed: 1 }, missingCategories: [], + coverageReport: null, }} />, ); - expect( - screen.getByText("No approved test plan."), - ).toBeInTheDocument(); + expect(screen.getByText("No approved test plan.")).toBeInTheDocument(); expect(screen.getByText("Tests are failing.")).toBeInTheDocument(); }); @@ -132,9 +131,7 @@ describe("GatePanel", () => { const onRefresh = vi.fn(); render(); - await userEvent.click( - screen.getByRole("button", { name: "Refresh" }), - ); + await userEvent.click(screen.getByRole("button", { name: "Refresh" })); expect(onRefresh).toHaveBeenCalledOnce(); }); diff --git a/frontend/src/components/TodoPanel.tsx b/frontend/src/components/TodoPanel.tsx new file mode 100644 index 0000000..a6836ea --- /dev/null +++ b/frontend/src/components/TodoPanel.tsx @@ -0,0 +1,175 @@ +interface StoryTodos { + storyId: string; + storyName: string | null; + items: string[]; +} + +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); + + return ( +
+
+
+
Story TODOs
+ +
+
+
{totalTodos} remaining
+
+ Updated {formatTimestamp(lastTodoRefresh)} +
+
+
+ + {isTodoLoading ? ( +
+ Loading story TODOs... +
+ ) : todoError ? ( +
+ {todoError} + +
+ ) : totalTodos === 0 ? ( +
+ All acceptance criteria complete. +
+ ) : ( +
+ {todos + .filter((s) => s.items.length > 0) + .map((story) => ( +
+ {todos.length > 1 && ( +
+ {story.storyName ?? story.storyId} +
+ )} +
    + {story.items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/selection/usePathCompletion.test.ts b/frontend/src/components/selection/usePathCompletion.test.ts index ab785f6..d33df8e 100644 --- a/frontend/src/components/selection/usePathCompletion.test.ts +++ b/frontend/src/components/selection/usePathCompletion.test.ts @@ -1,5 +1,5 @@ import { act, renderHook, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { FileEntry } from "./usePathCompletion"; import { getCurrentPartial, diff --git a/frontend/tests/e2e/story-todos.spec.ts b/frontend/tests/e2e/story-todos.spec.ts new file mode 100644 index 0000000..a20492f --- /dev/null +++ b/frontend/tests/e2e/story-todos.spec.ts @@ -0,0 +1,166 @@ +import { expect, test } from "@playwright/test"; +import type { + AcceptanceResponse, + ReviewListResponse, + TodoListResponse, +} from "../../src/api/workflow"; + +function mockChatApis( + page: import("@playwright/test").Page, + overrides: { + acceptance?: AcceptanceResponse; + reviewQueue?: ReviewListResponse; + todos?: TodoListResponse; + } = {}, +) { + const acceptance: AcceptanceResponse = overrides.acceptance ?? { + can_accept: false, + reasons: ["No test results recorded for the story."], + warning: null, + summary: { total: 0, passed: 0, failed: 0 }, + missing_categories: ["unit", "integration"], + }; + + const reviewQueue: ReviewListResponse = overrides.reviewQueue ?? { + stories: [], + }; + + const todos: TodoListResponse = overrides.todos ?? { + stories: [], + }; + + return Promise.all([ + page.route("**/api/projects", (route) => + route.fulfill({ json: ["/tmp/test-project"] }), + ), + page.route("**/api/io/fs/home", (route) => + route.fulfill({ json: "/tmp" }), + ), + page.route("**/api/project", (route) => { + if (route.request().method() === "POST") { + return route.fulfill({ json: "/tmp/test-project" }); + } + if (route.request().method() === "DELETE") { + return route.fulfill({ json: true }); + } + return route.fulfill({ json: null }); + }), + page.route("**/api/ollama/models**", (route) => + route.fulfill({ json: ["llama3.1"] }), + ), + page.route("**/api/anthropic/key/exists", (route) => + route.fulfill({ json: false }), + ), + page.route("**/api/anthropic/models", (route) => + route.fulfill({ json: [] }), + ), + page.route("**/api/model", (route) => { + if (route.request().method() === "POST") { + return route.fulfill({ json: true }); + } + return route.fulfill({ json: null }); + }), + page.route("**/api/workflow/acceptance", (route) => { + if (route.request().url().includes("/ensure")) return route.fallback(); + return route.fulfill({ json: acceptance }); + }), + page.route("**/api/workflow/review/all", (route) => + route.fulfill({ json: reviewQueue }), + ), + page.route("**/api/workflow/acceptance/ensure", (route) => + route.fulfill({ json: true }), + ), + page.route("**/api/io/fs/list/absolute**", (route) => + route.fulfill({ json: [] }), + ), + page.route("**/api/workflow/todos", (route) => + route.fulfill({ json: todos }), + ), + ]); +} + +async function openProject(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByPlaceholder("/path/to/project").fill("/tmp/test-project"); + await page.getByRole("button", { name: "Open Project" }).click(); + await expect(page.getByText("Story TODOs", { exact: true })).toBeVisible(); +} + +test.describe("Story TODOs panel", () => { + test("shows unchecked acceptance criteria", async ({ page }) => { + await mockChatApis(page, { + todos: { + 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.", + ], + }, + ], + }, + }); + + await openProject(page); + + await expect( + page.getByText("The UI lists unchecked acceptance criteria."), + ).toBeVisible(); + await expect( + page.getByText("Each TODO is displayed as its full text."), + ).toBeVisible(); + await expect(page.getByText("2 remaining")).toBeVisible(); + }); + + test("shows completion message when all criteria are checked", async ({ + page, + }) => { + await mockChatApis(page, { + todos: { + stories: [ + { + story_id: "28_ui_show_test_todos", + story_name: "Show Remaining Test TODOs in the UI", + todos: [], + }, + ], + }, + }); + + await openProject(page); + + await expect( + page.getByText("All acceptance criteria complete."), + ).toBeVisible(); + await expect(page.getByText("0 remaining")).toBeVisible(); + }); + + test("shows TODO items from multiple stories", async ({ page }) => { + await mockChatApis(page, { + todos: { + stories: [ + { + story_id: "28_ui_show_test_todos", + story_name: "Show TODOs", + todos: ["First criterion."], + }, + { + story_id: "29_another_story", + story_name: "Another Story", + todos: ["Second criterion."], + }, + ], + }, + }); + + await openProject(page); + + await expect(page.getByText("First criterion.")).toBeVisible(); + await expect(page.getByText("Second criterion.")).toBeVisible(); + await expect(page.getByText("2 remaining")).toBeVisible(); + await expect(page.getByText("Show TODOs")).toBeVisible(); + await expect(page.getByText("Another Story")).toBeVisible(); + }); +}); diff --git a/frontend/tests/e2e/tdd-gates.spec.ts b/frontend/tests/e2e/tdd-gates.spec.ts index c511a26..0f24d6f 100644 --- a/frontend/tests/e2e/tdd-gates.spec.ts +++ b/frontend/tests/e2e/tdd-gates.spec.ts @@ -81,6 +81,9 @@ function mockChatApis( page.route("**/api/io/fs/list/absolute**", (route) => route.fulfill({ json: [] }), ), + page.route("**/api/workflow/todos", (route) => + route.fulfill({ json: { stories: [] } }), + ), ]); } diff --git a/frontend/tests/e2e/test-protection.spec.ts b/frontend/tests/e2e/test-protection.spec.ts index c4c9101..f4834bd 100644 --- a/frontend/tests/e2e/test-protection.spec.ts +++ b/frontend/tests/e2e/test-protection.spec.ts @@ -68,6 +68,9 @@ function mockChatApis( page.route("**/api/io/fs/list/absolute**", (route) => route.fulfill({ json: [] }), ), + page.route("**/api/workflow/todos", (route) => + route.fulfill({ json: { stories: [] } }), + ), ]); } diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index 82d9b2e..6a7479c 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -1,5 +1,5 @@ use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use crate::io::story_metadata::{StoryMetadata, parse_front_matter}; +use crate::io::story_metadata::{StoryMetadata, parse_front_matter, parse_unchecked_todos}; use crate::workflow::{ CoverageReport, StoryTestResults, TestCaseResult, TestStatus, evaluate_acceptance_with_coverage, parse_coverage_json, summarize_results, @@ -87,6 +87,18 @@ struct ReviewListResponse { pub stories: Vec, } +#[derive(Object)] +struct StoryTodosResponse { + pub story_id: String, + pub story_name: Option, + pub todos: Vec, +} + +#[derive(Object)] +struct TodoListResponse { + pub stories: Vec, +} + fn load_current_story_metadata(ctx: &AppContext) -> Result, String> { let root = ctx.state.get_project_root()?; let current_dir = root.join(".story_kit").join("stories").join("current"); @@ -403,6 +415,51 @@ impl WorkflowApi { })) } + /// List unchecked acceptance criteria (TODOs) for all current stories. + #[oai(path = "/workflow/todos", method = "get")] + async fn story_todos(&self) -> OpenApiResult> { + let root = self.ctx.state.get_project_root().map_err(bad_request)?; + let current_dir = root.join(".story_kit").join("stories").join("current"); + + if !current_dir.exists() { + return Ok(Json(TodoListResponse { + stories: Vec::new(), + })); + } + + let mut stories = Vec::new(); + let mut entries: Vec<_> = fs::read_dir(¤t_dir) + .map_err(|e| bad_request(format!("Failed to read current stories: {e}")))? + .filter_map(|e| e.ok()) + .collect(); + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("md") { + continue; + } + let story_id = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default() + .to_string(); + let contents = fs::read_to_string(&path) + .map_err(|e| bad_request(format!("Failed to read {}: {e}", path.display())))?; + let story_name = parse_front_matter(&contents) + .ok() + .and_then(|m| m.name); + let todos = parse_unchecked_todos(&contents); + stories.push(StoryTodosResponse { + story_id, + story_name, + todos, + }); + } + + Ok(Json(TodoListResponse { stories })) + } + /// Ensure a story can be accepted; returns an error when gates fail. #[oai(path = "/workflow/acceptance/ensure", method = "post")] async fn ensure_acceptance( diff --git a/server/src/io/story_metadata.rs b/server/src/io/story_metadata.rs index 86d2237..75a740e 100644 --- a/server/src/io/story_metadata.rs +++ b/server/src/io/story_metadata.rs @@ -59,6 +59,18 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata { } } +pub fn parse_unchecked_todos(contents: &str) -> Vec { + contents + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + trimmed + .strip_prefix("- [ ] ") + .map(|text| text.to_string()) + }) + .collect() +} + fn parse_test_plan_status(value: &str) -> TestPlanStatus { match value { "approved" => TestPlanStatus::Approved, @@ -108,4 +120,31 @@ workflow: tdd Err(StoryMetaError::InvalidFrontMatter(_)) )); } + + #[test] + fn parse_unchecked_todos_mixed() { + let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n"; + assert_eq!( + parse_unchecked_todos(input), + vec!["First thing", "Second thing"] + ); + } + + #[test] + fn parse_unchecked_todos_all_checked() { + let input = "- [x] Done\n- [x] Also done\n"; + assert!(parse_unchecked_todos(input).is_empty()); + } + + #[test] + fn parse_unchecked_todos_no_checkboxes() { + let input = "# Story\nSome text\n- A bullet\n"; + assert!(parse_unchecked_todos(input).is_empty()); + } + + #[test] + fn parse_unchecked_todos_leading_whitespace() { + let input = " - [ ] Indented item\n"; + assert_eq!(parse_unchecked_todos(input), vec!["Indented item"]); + } }