story-kit: merge 224_story_expand_work_item_to_full_screen_detail_view

This commit is contained in:
Dave
2026-02-27 11:21:35 +00:00
parent 3912f8ecc8
commit 101bfd78fe
6 changed files with 449 additions and 10 deletions

View File

@@ -101,6 +101,12 @@ export interface Message {
tool_call_id?: string; tool_call_id?: string;
} }
export interface WorkItemContent {
content: string;
stage: string;
name: string | null;
}
export interface FileEntry { export interface FileEntry {
name: string; name: string;
kind: "file" | "dir"; kind: "file" | "dir";
@@ -267,6 +273,13 @@ export const api = {
cancelChat(baseUrl?: string) { cancelChat(baseUrl?: string) {
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl); return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
}, },
getWorkItemContent(storyId: string, baseUrl?: string) {
return requestJson<WorkItemContent>(
`/work-items/${encodeURIComponent(storyId)}`,
{},
baseUrl,
);
},
}; };
export class ChatWebSocket { export class ChatWebSocket {

View File

@@ -13,6 +13,7 @@ import { ChatInput } from "./ChatInput";
import { LozengeFlyProvider } from "./LozengeFlyContext"; import { LozengeFlyProvider } from "./LozengeFlyContext";
import { MessageItem } from "./MessageItem"; import { MessageItem } from "./MessageItem";
import { StagePanel } from "./StagePanel"; import { StagePanel } from "./StagePanel";
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
const { useCallback, useEffect, useMemo, useRef, useState } = React; const { useCallback, useEffect, useMemo, useRef, useState } = React;
@@ -189,6 +190,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [agentStateVersion, setAgentStateVersion] = useState(0); const [agentStateVersion, setAgentStateVersion] = useState(0);
const [needsOnboarding, setNeedsOnboarding] = useState(false); const [needsOnboarding, setNeedsOnboarding] = useState(false);
const onboardingTriggeredRef = useRef(false); const onboardingTriggeredRef = useRef(false);
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
null,
);
const [queuedMessages, setQueuedMessages] = useState< const [queuedMessages, setQueuedMessages] = useState<
{ id: string; text: string }[] { id: string; text: string }[]
>([]); >([]);
@@ -879,16 +883,45 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}} }}
> >
<LozengeFlyProvider pipeline={pipeline}> <LozengeFlyProvider pipeline={pipeline}>
<AgentPanel {selectedWorkItemId ? (
configVersion={agentConfigVersion} <WorkItemDetailPanel
stateVersion={agentStateVersion} storyId={selectedWorkItemId}
/> onClose={() => setSelectedWorkItemId(null)}
/>
) : (
<>
<AgentPanel
configVersion={agentConfigVersion}
stateVersion={agentStateVersion}
/>
<StagePanel title="Done" items={pipeline.done ?? []} /> <StagePanel
<StagePanel title="To Merge" items={pipeline.merge} /> title="Done"
<StagePanel title="QA" items={pipeline.qa} /> items={pipeline.done ?? []}
<StagePanel title="Current" items={pipeline.current} /> onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
<StagePanel title="Upcoming" items={pipeline.upcoming} /> />
<StagePanel
title="To Merge"
items={pipeline.merge}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/>
<StagePanel
title="QA"
items={pipeline.qa}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/>
<StagePanel
title="Current"
items={pipeline.current}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/>
<StagePanel
title="Upcoming"
items={pipeline.upcoming}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/>
</>
)}
</LozengeFlyProvider> </LozengeFlyProvider>
</div> </div>
</div> </div>

View File

@@ -34,6 +34,7 @@ interface StagePanelProps {
title: string; title: string;
items: PipelineStageItem[]; items: PipelineStageItem[];
emptyMessage?: string; emptyMessage?: string;
onItemClick?: (item: PipelineStageItem) => void;
} }
function AgentLozenge({ function AgentLozenge({
@@ -119,6 +120,7 @@ export function StagePanel({
title, title,
items, items,
emptyMessage = "Empty.", emptyMessage = "Empty.",
onItemClick,
}: StagePanelProps) { }: StagePanelProps) {
return ( return (
<div <div
@@ -189,6 +191,20 @@ export function StagePanel({
gap: "2px", gap: "2px",
}} }}
> >
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 = (
<>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: "0.9em" }}> <div style={{ fontWeight: 600, fontSize: "0.9em" }}>
{hasMergeFailure && ( {hasMergeFailure && (
@@ -260,6 +276,25 @@ export function StagePanel({
{item.agent && ( {item.agent && (
<AgentLozenge agent={item.agent} storyId={item.story_id} /> <AgentLozenge agent={item.agent} storyId={item.story_id} />
)} )}
</>
);
return onItemClick ? (
<button
key={`${title}-${item.story_id}`}
type="button"
data-testid={`card-${item.story_id}`}
onClick={() => onItemClick(item)}
style={cardStyle}
>
{cardInner}
</button>
) : (
<div
key={`${title}-${item.story_id}`}
data-testid={`card-${item.story_id}`}
style={cardStyle}
>
{cardInner}
</div> </div>
); );
})} })}

View File

@@ -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<string, string> = {
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<string | null>(null);
const [stage, setStage] = useState<string>("");
const [name, setName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(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 (
<div
data-testid="work-item-detail-panel"
ref={panelRef}
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
background: "#1a1a1a",
borderRadius: "8px",
border: "1px solid #333",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
borderBottom: "1px solid #333",
flexShrink: 0,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "2px",
minWidth: 0,
}}
>
<div
data-testid="detail-panel-title"
style={{
fontWeight: 600,
fontSize: "0.95em",
color: "#ececec",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name ?? storyId}
</div>
{stage && (
<div
data-testid="detail-panel-stage"
style={{ fontSize: "0.75em", color: "#888" }}
>
{stageLabel}
</div>
)}
</div>
<button
type="button"
data-testid="detail-panel-close"
onClick={onClose}
style={{
background: "none",
border: "1px solid #444",
borderRadius: "6px",
color: "#aaa",
cursor: "pointer",
padding: "4px 10px",
fontSize: "0.8em",
flexShrink: 0,
}}
>
Close
</button>
</div>
{/* Scrollable content area */}
<div
style={{
flex: 1,
overflowY: "auto",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "16px",
}}
>
{loading && (
<div
data-testid="detail-panel-loading"
style={{ color: "#666", fontSize: "0.85em" }}
>
Loading...
</div>
)}
{error && (
<div
data-testid="detail-panel-error"
style={{ color: "#ff7b72", fontSize: "0.85em" }}
>
{error}
</div>
)}
{!loading && !error && content !== null && (
<div
data-testid="detail-panel-content"
className="markdown-body"
style={{ fontSize: "0.9em", lineHeight: 1.6 }}
>
<Markdown>{content}</Markdown>
</div>
)}
{/* Placeholder sections for future content */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{(
[
{ id: "agent-logs", label: "Agent Logs" },
{ id: "test-output", label: "Test Output" },
{ id: "coverage", label: "Coverage" },
] as { id: string; label: string }[]
).map(({ id, label }) => (
<div
key={id}
data-testid={`placeholder-${id}`}
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "10px 12px",
background: "#161616",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "0.8em",
color: "#555",
marginBottom: "4px",
}}
>
{label}
</div>
<div style={{ fontSize: "0.75em", color: "#444" }}>
Coming soon
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
use crate::config::ProjectConfig; 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 crate::worktree;
use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json}; use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
use serde::Serialize; use serde::Serialize;
@@ -61,6 +61,14 @@ struct WorktreeListEntry {
path: String, path: String,
} }
/// Response for the work item content endpoint.
#[derive(Object, Serialize)]
struct WorkItemContentResponse {
content: String,
stage: String,
name: Option<String>,
}
/// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`. /// 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` /// 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<String>,
) -> OpenApiResult<Json<WorkItemContentResponse>> {
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. /// Remove a git worktree and its feature branch for a story.
#[oai(path = "/agents/worktrees/:story_id", method = "delete")] #[oai(path = "/agents/worktrees/:story_id", method = "delete")]
async fn remove_worktree(&self, story_id: Path<String>) -> OpenApiResult<Json<bool>> { async fn remove_worktree(&self, story_id: Path<String>) -> OpenApiResult<Json<bool>> {
@@ -627,6 +681,86 @@ allowed_tools = ["Read", "Bash"]
assert!(result.is_err()); 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 --- // --- create_worktree error path ---
#[tokio::test] #[tokio::test]

View File

@@ -79,6 +79,10 @@ pub fn bad_request(message: String) -> poem::Error {
poem::Error::from_string(message, StatusCode::BAD_REQUEST) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -102,5 +106,8 @@ mod tests {
assert_eq!(PermissionDecision::AlwaysAllow, PermissionDecision::AlwaysAllow); assert_eq!(PermissionDecision::AlwaysAllow, PermissionDecision::AlwaysAllow);
assert_ne!(PermissionDecision::Deny, PermissionDecision::Approve); assert_ne!(PermissionDecision::Deny, PermissionDecision::Approve);
assert_ne!(PermissionDecision::Approve, PermissionDecision::AlwaysAllow); 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);
} }
} }