Merge story-31: View Upcoming Stories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> # Conflicts: # frontend/src/api/workflow.ts # frontend/src/components/Chat.test.tsx # frontend/src/components/Chat.tsx # server/src/http/workflow.rs
This commit is contained in:
@@ -36,6 +36,7 @@ vi.mock("../api/workflow", () => {
|
||||
recordCoverage: vi.fn(),
|
||||
collectCoverage: vi.fn(),
|
||||
getStoryTodos: vi.fn(),
|
||||
getUpcomingStories: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -56,6 +57,7 @@ const mockedWorkflow = {
|
||||
getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll),
|
||||
ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance),
|
||||
getStoryTodos: vi.mocked(workflowApi.getStoryTodos),
|
||||
getUpcomingStories: vi.mocked(workflowApi.getUpcomingStories),
|
||||
};
|
||||
|
||||
describe("Chat review panel", () => {
|
||||
@@ -78,6 +80,7 @@ describe("Chat review panel", () => {
|
||||
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
|
||||
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
|
||||
});
|
||||
|
||||
it("shows an empty review queue state", async () => {
|
||||
@@ -469,6 +472,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({
|
||||
|
||||
@@ -3,13 +3,14 @@ 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 { TodoPanel } from "./TodoPanel";
|
||||
import { UpcomingPanel } from "./UpcomingPanel";
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
@@ -68,6 +69,12 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
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 storyId = "26_establish_tdd_workflow_and_gates";
|
||||
const gateStatusColor = isGateLoading
|
||||
@@ -375,6 +382,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);
|
||||
@@ -676,6 +735,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
lastTodoRefresh={lastTodoRefresh}
|
||||
onRefresh={refreshTodos}
|
||||
/>
|
||||
|
||||
<UpcomingPanel
|
||||
stories={upcomingStories}
|
||||
isLoading={isUpcomingLoading}
|
||||
error={upcomingError}
|
||||
lastRefresh={lastUpcomingRefresh}
|
||||
onRefresh={refreshUpcomingStories}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
76
frontend/src/components/UpcomingPanel.test.tsx
Normal file
76
frontend/src/components/UpcomingPanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
169
frontend/src/components/UpcomingPanel.tsx
Normal file
169
frontend/src/components/UpcomingPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user