diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index efc28e1..501f460 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -475,8 +475,8 @@ describe("Chat review panel", () => { it("fetches upcoming stories on mount and renders panel", async () => { mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({ stories: [ - { story_id: "31_view_upcoming", name: "View Upcoming Stories" }, - { story_id: "32_worktree", name: null }, + { story_id: "31_view_upcoming", name: "View Upcoming Stories", error: null }, + { story_id: "32_worktree", name: null, error: null }, ], }); @@ -544,6 +544,7 @@ describe("Chat review panel", () => { "The UI lists unchecked acceptance criteria.", "Each TODO is displayed as its full text.", ], + error: null, }, ], }); @@ -566,6 +567,7 @@ describe("Chat review panel", () => { story_id: "28_ui_show_test_todos", story_name: "Show Remaining Test TODOs in the UI", todos: [], + error: null, }, ], }); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 1805cf0..a963d16 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -65,7 +65,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [isCollectingCoverage, setIsCollectingCoverage] = useState(false); const [coverageError, setCoverageError] = useState(null); const [storyTodos, setStoryTodos] = useState< - { storyId: string; storyName: string | null; items: string[] }[] + { + storyId: string; + storyName: string | null; + items: string[]; + error: string | null; + }[] >([]); const [todoError, setTodoError] = useState(null); const [isTodoLoading, setIsTodoLoading] = useState(false); @@ -284,6 +289,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { storyId: s.story_id, storyName: s.story_name, items: s.todos, + error: s.error ?? null, })), ); setLastTodoRefresh(new Date()); @@ -319,6 +325,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { storyId: s.story_id, storyName: s.story_name, items: s.todos, + error: s.error ?? null, })), ); setLastTodoRefresh(new Date()); diff --git a/frontend/src/components/TodoPanel.test.tsx b/frontend/src/components/TodoPanel.test.tsx new file mode 100644 index 0000000..10bf36b --- /dev/null +++ b/frontend/src/components/TodoPanel.test.tsx @@ -0,0 +1,76 @@ +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( + , + ); + + expect(screen.getByText("Missing front matter")).toBeInTheDocument(); + expect(screen.getByText("28_todos")).toBeInTheDocument(); + }); + + it("shows error alongside todo items", () => { + render( + , + ); + + 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( + , + ); + + expect(screen.queryByTestId("story-error-28_todos")).toBeNull(); + expect(screen.getByText("A criterion")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/TodoPanel.tsx b/frontend/src/components/TodoPanel.tsx index 7694509..324b70f 100644 --- a/frontend/src/components/TodoPanel.tsx +++ b/frontend/src/components/TodoPanel.tsx @@ -2,6 +2,7 @@ interface StoryTodos { storyId: string; storyName: string | null; items: string[]; + error: string | null; } interface TodoPanelProps { @@ -29,6 +30,7 @@ export function TodoPanel({ onRefresh, }: TodoPanelProps) { const totalTodos = todos.reduce((sum, s) => sum + s.items.length, 0); + const hasErrors = todos.some((s) => s.error); return (
- ) : totalTodos === 0 ? ( + ) : totalTodos === 0 && !hasErrors ? (
All acceptance criteria complete.
@@ -140,7 +142,7 @@ export function TodoPanel({ }} > {todos - .filter((s) => s.items.length > 0) + .filter((s) => s.items.length > 0 || s.error) .map((story) => (
{story.storyName ?? story.storyId}
-
    - {story.items.map((item) => ( -
  • {item}
  • - ))} -
+ {story.error && ( +
+ {story.error} +
+ )} + {story.items.length > 0 && ( +
    + {story.items.map((item) => ( +
  • {item}
  • + ))} +
+ )}
))} diff --git a/frontend/src/components/UpcomingPanel.test.tsx b/frontend/src/components/UpcomingPanel.test.tsx index f63e7f8..da37874 100644 --- a/frontend/src/components/UpcomingPanel.test.tsx +++ b/frontend/src/components/UpcomingPanel.test.tsx @@ -43,8 +43,8 @@ describe("UpcomingPanel", () => { it("renders story list with names", () => { const stories: UpcomingStory[] = [ - { story_id: "31_view_upcoming", name: "View Upcoming Stories" }, - { story_id: "32_worktree", name: "Worktree Orchestration" }, + { story_id: "31_view_upcoming", name: "View Upcoming Stories", error: null }, + { story_id: "32_worktree", name: "Worktree Orchestration", error: null }, ]; render(); @@ -55,7 +55,9 @@ describe("UpcomingPanel", () => { }); it("renders story without name using story_id", () => { - const stories: UpcomingStory[] = [{ story_id: "33_no_name", name: null }]; + const stories: UpcomingStory[] = [ + { story_id: "33_no_name", name: null, error: null }, + ]; render(); expect(screen.getByText("33_no_name")).toBeInTheDocument(); diff --git a/frontend/src/components/UpcomingPanel.tsx b/frontend/src/components/UpcomingPanel.tsx index 3a5b497..a32db72 100644 --- a/frontend/src/components/UpcomingPanel.tsx +++ b/frontend/src/components/UpcomingPanel.tsx @@ -146,20 +146,41 @@ export function UpcomingPanel({ gap: "8px", }} > -
- {story.name ?? story.story_id} -
- {story.name && ( +
- {story.story_id} +
+ {story.name ?? story.story_id} +
+ {story.name && ( +
+ {story.story_id} +
+ )}
- )} + {story.error && ( +
+ {story.error} +
+ )} +
))} diff --git a/frontend/tests/e2e/story-todos.spec.ts b/frontend/tests/e2e/story-todos.spec.ts index a20492f..f84663c 100644 --- a/frontend/tests/e2e/story-todos.spec.ts +++ b/frontend/tests/e2e/story-todos.spec.ts @@ -98,6 +98,7 @@ test.describe("Story TODOs panel", () => { "The UI lists unchecked acceptance criteria.", "Each TODO is displayed as its full text.", ], + error: null, }, ], }, @@ -124,6 +125,7 @@ test.describe("Story TODOs panel", () => { story_id: "28_ui_show_test_todos", story_name: "Show Remaining Test TODOs in the UI", todos: [], + error: null, }, ], }, @@ -137,6 +139,26 @@ test.describe("Story TODOs panel", () => { await expect(page.getByText("0 remaining")).toBeVisible(); }); + test("shows per-story front matter error", async ({ page }) => { + await mockChatApis(page, { + todos: { + stories: [ + { + story_id: "28_ui_show_test_todos", + story_name: null, + todos: [], + error: "Missing front matter", + }, + ], + }, + }); + + await openProject(page); + + await expect(page.getByText("Missing front matter")).toBeVisible(); + await expect(page.getByText("28_ui_show_test_todos")).toBeVisible(); + }); + test("shows TODO items from multiple stories", async ({ page }) => { await mockChatApis(page, { todos: { @@ -145,11 +167,13 @@ test.describe("Story TODOs panel", () => { story_id: "28_ui_show_test_todos", story_name: "Show TODOs", todos: ["First criterion."], + error: null, }, { story_id: "29_another_story", story_name: "Another Story", todos: ["Second criterion."], + error: null, }, ], }, diff --git a/server/src/io/story_metadata.rs b/server/src/io/story_metadata.rs index 75a740e..723f109 100644 --- a/server/src/io/story_metadata.rs +++ b/server/src/io/story_metadata.rs @@ -19,6 +19,15 @@ pub enum StoryMetaError { InvalidFrontMatter(String), } +impl std::fmt::Display for StoryMetaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StoryMetaError::MissingFrontMatter => write!(f, "Missing front matter"), + StoryMetaError::InvalidFrontMatter(msg) => write!(f, "Invalid front matter: {msg}"), + } + } +} + #[derive(Debug, Deserialize)] struct FrontMatter { name: Option,