From 101bfd78fee21c89d1214bd3b74acf381d0c3376 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 27 Feb 2026 11:21:35 +0000 Subject: [PATCH] story-kit: merge 224_story_expand_work_item_to_full_screen_detail_view --- frontend/src/api/client.ts | 13 ++ frontend/src/components/Chat.tsx | 51 +++- frontend/src/components/StagePanel.tsx | 35 +++ .../src/components/WorkItemDetailPanel.tsx | 217 ++++++++++++++++++ server/src/http/agents.rs | 136 ++++++++++- server/src/http/context.rs | 7 + 6 files changed, 449 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/WorkItemDetailPanel.tsx diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3fadffb..e04536e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -101,6 +101,12 @@ export interface Message { tool_call_id?: string; } +export interface WorkItemContent { + content: string; + stage: string; + name: string | null; +} + export interface FileEntry { name: string; kind: "file" | "dir"; @@ -267,6 +273,13 @@ export const api = { cancelChat(baseUrl?: string) { return requestJson("/chat/cancel", { method: "POST" }, baseUrl); }, + getWorkItemContent(storyId: string, baseUrl?: string) { + return requestJson( + `/work-items/${encodeURIComponent(storyId)}`, + {}, + baseUrl, + ); + }, }; export class ChatWebSocket { diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index dfe39e9..c73a316 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -13,6 +13,7 @@ import { ChatInput } from "./ChatInput"; import { LozengeFlyProvider } from "./LozengeFlyContext"; import { MessageItem } from "./MessageItem"; import { StagePanel } from "./StagePanel"; +import { WorkItemDetailPanel } from "./WorkItemDetailPanel"; const { useCallback, useEffect, useMemo, useRef, useState } = React; @@ -189,6 +190,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [agentStateVersion, setAgentStateVersion] = useState(0); const [needsOnboarding, setNeedsOnboarding] = useState(false); const onboardingTriggeredRef = useRef(false); + const [selectedWorkItemId, setSelectedWorkItemId] = useState( + null, + ); const [queuedMessages, setQueuedMessages] = useState< { id: string; text: string }[] >([]); @@ -879,16 +883,45 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { }} > - + {selectedWorkItemId ? ( + setSelectedWorkItemId(null)} + /> + ) : ( + <> + - - - - - + setSelectedWorkItemId(item.story_id)} + /> + setSelectedWorkItemId(item.story_id)} + /> + setSelectedWorkItemId(item.story_id)} + /> + setSelectedWorkItemId(item.story_id)} + /> + setSelectedWorkItemId(item.story_id)} + /> + + )} diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index 7ae4042..0c3e8a8 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -34,6 +34,7 @@ interface StagePanelProps { title: string; items: PipelineStageItem[]; emptyMessage?: string; + onItemClick?: (item: PipelineStageItem) => void; } function AgentLozenge({ @@ -119,6 +120,7 @@ export function StagePanel({ title, items, emptyMessage = "Empty.", + onItemClick, }: StagePanelProps) { return (
+ const cardStyle = { + border: item.agent ? "1px solid #2a3a4a" : "1px solid #2a2a2a", + borderRadius: "8px", + padding: "8px 12px", + background: item.agent ? "#161e2a" : "#191919", + display: "flex", + flexDirection: "column" as const, + gap: "2px", + width: "100%", + textAlign: "left" as const, + cursor: onItemClick ? "pointer" : "default", + }; + const cardInner = ( + <>
{hasMergeFailure && ( @@ -260,6 +276,25 @@ export function StagePanel({ {item.agent && ( )} + + ); + return onItemClick ? ( + + ) : ( +
+ {cardInner}
); })} diff --git a/frontend/src/components/WorkItemDetailPanel.tsx b/frontend/src/components/WorkItemDetailPanel.tsx new file mode 100644 index 0000000..1e58611 --- /dev/null +++ b/frontend/src/components/WorkItemDetailPanel.tsx @@ -0,0 +1,217 @@ +import * as React from "react"; +import Markdown from "react-markdown"; +import { api } from "../api/client"; + +const { useEffect, useRef, useState } = React; + +const STAGE_LABELS: Record = { + upcoming: "Upcoming", + current: "Current", + qa: "QA", + merge: "To Merge", + done: "Done", + archived: "Archived", +}; + +interface WorkItemDetailPanelProps { + storyId: string; + onClose: () => void; +} + +export function WorkItemDetailPanel({ + storyId, + onClose, +}: WorkItemDetailPanelProps) { + const [content, setContent] = useState(null); + const [stage, setStage] = useState(""); + const [name, setName] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const panelRef = useRef(null); + + useEffect(() => { + setLoading(true); + setError(null); + api + .getWorkItemContent(storyId) + .then((data) => { + setContent(data.content); + setStage(data.stage); + setName(data.name); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Failed to load content"); + }) + .finally(() => { + setLoading(false); + }); + }, [storyId]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const stageLabel = STAGE_LABELS[stage] ?? stage; + + return ( +
+ {/* Header */} +
+
+
+ {name ?? storyId} +
+ {stage && ( +
+ {stageLabel} +
+ )} +
+ +
+ + {/* Scrollable content area */} +
+ {loading && ( +
+ Loading... +
+ )} + {error && ( +
+ {error} +
+ )} + {!loading && !error && content !== null && ( +
+ {content} +
+ )} + + {/* Placeholder sections for future content */} +
+ {( + [ + { id: "agent-logs", label: "Agent Logs" }, + { id: "test-output", label: "Test Output" }, + { id: "coverage", label: "Coverage" }, + ] as { id: string; label: string }[] + ).map(({ id, label }) => ( +
+
+ {label} +
+
+ Coming soon +
+
+ ))} +
+
+
+ ); +} diff --git a/server/src/http/agents.rs b/server/src/http/agents.rs index 661d453..b6c9668 100644 --- a/server/src/http/agents.rs +++ b/server/src/http/agents.rs @@ -1,5 +1,5 @@ use crate::config::ProjectConfig; -use crate::http::context::{AppContext, OpenApiResult, bad_request}; +use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found}; use crate::worktree; use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json}; use serde::Serialize; @@ -61,6 +61,14 @@ struct WorktreeListEntry { path: String, } +/// Response for the work item content endpoint. +#[derive(Object, Serialize)] +struct WorkItemContentResponse { + content: String, + stage: String, + name: Option, +} + /// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`. /// /// Used to exclude agents for already-archived stories from the `list_agents` @@ -272,6 +280,52 @@ impl AgentsApi { )) } + /// Get the markdown content of a work item by its story_id. + /// + /// Searches all active pipeline stages for the file and returns its content + /// along with the stage it was found in. + #[oai(path = "/work-items/:story_id", method = "get")] + async fn get_work_item_content( + &self, + story_id: Path, + ) -> OpenApiResult> { + let project_root = self + .ctx + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + let stages = [ + ("1_upcoming", "upcoming"), + ("2_current", "current"), + ("3_qa", "qa"), + ("4_merge", "merge"), + ("5_done", "done"), + ("6_archived", "archived"), + ]; + + let work_dir = project_root.join(".story_kit").join("work"); + let filename = format!("{}.md", story_id.0); + + for (stage_dir, stage_name) in &stages { + let file_path = work_dir.join(stage_dir).join(&filename); + if file_path.exists() { + let content = std::fs::read_to_string(&file_path) + .map_err(|e| bad_request(format!("Failed to read work item: {e}")))?; + let name = crate::io::story_metadata::parse_front_matter(&content) + .ok() + .and_then(|m| m.name); + return Ok(Json(WorkItemContentResponse { + content, + stage: stage_name.to_string(), + name, + })); + } + } + + Err(not_found(format!("Work item not found: {}", story_id.0))) + } + /// Remove a git worktree and its feature branch for a story. #[oai(path = "/agents/worktrees/:story_id", method = "delete")] async fn remove_worktree(&self, story_id: Path) -> OpenApiResult> { @@ -627,6 +681,86 @@ allowed_tools = ["Read", "Bash"] assert!(result.is_err()); } + // --- get_work_item_content tests --- + + fn make_stage_dir(root: &path::Path, stage: &str) { + std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap(); + } + + #[tokio::test] + async fn get_work_item_content_returns_content_from_upcoming() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + make_stage_dir(root, "1_upcoming"); + std::fs::write( + root.join(".story_kit/work/1_upcoming/42_story_foo.md"), + "---\nname: \"Foo Story\"\n---\n\n# Story 42: Foo Story\n\nSome content.", + ) + .unwrap(); + let ctx = AppContext::new_test(root.to_path_buf()); + let api = AgentsApi { + ctx: Arc::new(ctx), + }; + let result = api + .get_work_item_content(Path("42_story_foo".to_string())) + .await + .unwrap() + .0; + assert!(result.content.contains("Some content.")); + assert_eq!(result.stage, "upcoming"); + assert_eq!(result.name, Some("Foo Story".to_string())); + } + + #[tokio::test] + async fn get_work_item_content_returns_content_from_current() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + make_stage_dir(root, "2_current"); + std::fs::write( + root.join(".story_kit/work/2_current/43_story_bar.md"), + "---\nname: \"Bar Story\"\n---\n\nBar content.", + ) + .unwrap(); + let ctx = AppContext::new_test(root.to_path_buf()); + let api = AgentsApi { + ctx: Arc::new(ctx), + }; + let result = api + .get_work_item_content(Path("43_story_bar".to_string())) + .await + .unwrap() + .0; + assert_eq!(result.stage, "current"); + assert_eq!(result.name, Some("Bar Story".to_string())); + } + + #[tokio::test] + async fn get_work_item_content_returns_not_found_when_absent() { + let tmp = TempDir::new().unwrap(); + let ctx = AppContext::new_test(tmp.path().to_path_buf()); + let api = AgentsApi { + ctx: Arc::new(ctx), + }; + let result = api + .get_work_item_content(Path("99_story_nonexistent".to_string())) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn get_work_item_content_returns_error_when_no_project_root() { + let tmp = TempDir::new().unwrap(); + let ctx = AppContext::new_test(tmp.path().to_path_buf()); + *ctx.state.project_root.lock().unwrap() = None; + let api = AgentsApi { + ctx: Arc::new(ctx), + }; + let result = api + .get_work_item_content(Path("42_story_foo".to_string())) + .await; + assert!(result.is_err()); + } + // --- create_worktree error path --- #[tokio::test] diff --git a/server/src/http/context.rs b/server/src/http/context.rs index 370f3ed..cdece1f 100644 --- a/server/src/http/context.rs +++ b/server/src/http/context.rs @@ -79,6 +79,10 @@ pub fn bad_request(message: String) -> poem::Error { poem::Error::from_string(message, StatusCode::BAD_REQUEST) } +pub fn not_found(message: String) -> poem::Error { + poem::Error::from_string(message, StatusCode::NOT_FOUND) +} + #[cfg(test)] mod tests { use super::*; @@ -102,5 +106,8 @@ mod tests { assert_eq!(PermissionDecision::AlwaysAllow, PermissionDecision::AlwaysAllow); assert_ne!(PermissionDecision::Deny, PermissionDecision::Approve); assert_ne!(PermissionDecision::Approve, PermissionDecision::AlwaysAllow); + fn not_found_returns_404_status() { + let err = not_found("item not found".to_string()); + assert_eq!(err.status(), StatusCode::NOT_FOUND); } }