Story 31: View Upcoming Stories

Add GET /workflow/upcoming endpoint that reads .story_kit/stories/upcoming/
and returns story IDs with names parsed from frontmatter. Add UpcomingPanel
component wired into Chat view with loading, error, empty, and list states.

12 new tests (3 backend, 9 frontend) all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-19 15:51:12 +00:00
parent 644644d5b3
commit 939387104b
12 changed files with 505 additions and 18 deletions

View File

@@ -40,6 +40,7 @@ vi.mock("./api/workflow", () => {
missing_categories: [],
}),
getReviewQueueAll: vi.fn().mockResolvedValue({ stories: [] }),
getUpcomingStories: vi.fn().mockResolvedValue({ stories: [] }),
recordTests: vi.fn(),
ensureAcceptance: vi.fn(),
getReviewQueue: vi.fn(),

View File

@@ -98,6 +98,28 @@ describe("workflowApi", () => {
});
});
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(

View File

@@ -62,6 +62,15 @@ export interface ReviewListResponse {
stories: ReviewStory[];
}
export interface UpcomingStory {
story_id: string;
name: string | null;
}
export interface UpcomingStoriesResponse {
stories: UpcomingStory[];
}
const DEFAULT_API_BASE = "/api";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
@@ -124,6 +133,13 @@ export const workflowApi = {
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",

View File

@@ -35,6 +35,7 @@ vi.mock("../api/workflow", () => {
ensureAcceptance: vi.fn(),
recordCoverage: vi.fn(),
collectCoverage: vi.fn(),
getUpcomingStories: vi.fn(),
},
};
});
@@ -54,6 +55,7 @@ const mockedWorkflow = {
getReviewQueue: vi.mocked(workflowApi.getReviewQueue),
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
getUpcomingStories: vi.mocked(workflowApi.getUpcomingStories),
};
describe("Chat review panel", () => {
@@ -75,6 +77,7 @@ describe("Chat review panel", () => {
});
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
});
it("shows an empty review queue state", async () => {
@@ -466,6 +469,23 @@ describe("Chat review panel", () => {
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" },
{ story_id: "32_worktree", name: null },
],
});
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
expect(await screen.findByText("Upcoming Stories")).toBeInTheDocument();
expect(
await screen.findByText("View Upcoming Stories"),
).toBeInTheDocument();
expect(await screen.findByText("32_worktree")).toBeInTheDocument();
});
it("collect coverage button triggers collection and refreshes gate", async () => {
const mockedCollectCoverage = vi.mocked(workflowApi.collectCoverage);
mockedCollectCoverage.mockResolvedValueOnce({

View File

@@ -3,12 +3,13 @@ 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 } from "../api/workflow";
import type { ReviewStory, UpcomingStory } from "../api/workflow";
import { workflowApi } from "../api/workflow";
import type { Message, ProviderConfig, ToolCall } from "../types";
import { ChatHeader } from "./ChatHeader";
import { GatePanel } from "./GatePanel";
import { ReviewPanel } from "./ReviewPanel";
import { UpcomingPanel } from "./UpcomingPanel";
const { useCallback, useEffect, useRef, useState } = React;
@@ -61,6 +62,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [lastGateRefresh, setLastGateRefresh] = useState<Date | null>(null);
const [isCollectingCoverage, setIsCollectingCoverage] = useState(false);
const [coverageError, setCoverageError] = useState<string | 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 storyId = "26_establish_tdd_workflow_and_gates";
const gateStatusColor = isGateLoading
@@ -306,6 +313,58 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}
};
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);
@@ -593,6 +652,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
onCollectCoverage={handleCollectCoverage}
isCollectingCoverage={isCollectingCoverage}
/>
<UpcomingPanel
stories={upcomingStories}
isLoading={isUpcomingLoading}
error={upcomingError}
lastRefresh={lastUpcomingRefresh}
onRefresh={refreshUpcomingStories}
/>
</div>
</div>

View File

@@ -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(<GatePanel {...baseProps} isGateLoading={true} />);
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(<GatePanel {...baseProps} onRefresh={onRefresh} />);
await userEvent.click(
screen.getByRole("button", { name: "Refresh" }),
);
await userEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(onRefresh).toHaveBeenCalledOnce();
});

View File

@@ -0,0 +1,76 @@
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" },
{ story_id: "32_worktree", name: "Worktree Orchestration" },
];
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 }];
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();
});
});

View File

@@ -0,0 +1,169 @@
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={{ 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>
))}
</div>
)}
</div>
);
}

View File

@@ -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,