diff --git a/.huskies/work/1_backlog/487_story_display_story_dependencies_in_web_ui_and_chat_commands.md b/.huskies/work/1_backlog/487_story_display_story_dependencies_in_web_ui_and_chat_commands.md new file mode 100644 index 00000000..7ccc5b7e --- /dev/null +++ b/.huskies/work/1_backlog/487_story_display_story_dependencies_in_web_ui_and_chat_commands.md @@ -0,0 +1,21 @@ +--- +name: "Display story dependencies in web UI and chat commands" +--- + +# Story 487: Display story dependencies in web UI and chat commands + +## User Story + +As a user managing stories with dependencies, I want to see dependency information in the web UI story panel and in chat/slash command output, so I can understand which stories are blocked and why without reading the raw markdown files. + +## Acceptance Criteria + +- [ ] The `UpcomingStory` API response includes a `depends_on` field (list of story numbers) when the story has dependencies in its front matter +- [ ] The `status ` chat command shows `depends_on` in the front matter fields section when present +- [ ] The web UI pipeline stage cards show a "Depends on: #N, #M" badge when a story has dependencies +- [ ] Stories with no `depends_on` field show no dependency indicator (no regressions) + +## Out of Scope + +- Rendering the dependency status (done/not done) in the UI — only the raw list of numbers is shown +- Adding or removing dependencies via the web UI (the existing `depends` chat command handles that) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3a773604..8e2d4b9e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -48,6 +48,7 @@ export interface PipelineStageItem { agent: AgentAssignment | null; review_hold: boolean | null; qa: string | null; + depends_on: number[] | null; } export interface PipelineState { diff --git a/frontend/src/components/LozengeFlyContext.test.tsx b/frontend/src/components/LozengeFlyContext.test.tsx index b6961a7e..fba8f748 100644 --- a/frontend/src/components/LozengeFlyContext.test.tsx +++ b/frontend/src/components/LozengeFlyContext.test.tsx @@ -61,6 +61,7 @@ describe("AgentLozenge fixed intrinsic width", () => { agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ]; const pipeline = makePipeline({ current: items }); @@ -115,6 +116,7 @@ describe("LozengeFlyProvider fly-in visibility", () => { agent: { agent_name: "coder-1", model: null, status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -157,6 +159,7 @@ describe("LozengeFlyProvider fly-in visibility", () => { }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -221,6 +224,7 @@ describe("LozengeFlyProvider fly-in clone", () => { agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -264,6 +268,7 @@ describe("LozengeFlyProvider fly-in clone", () => { agent: { agent_name: "coder-1", model: null, status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -313,6 +318,7 @@ describe("LozengeFlyProvider fly-in clone", () => { agent: { agent_name: "coder-1", model: null, status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -384,6 +390,7 @@ describe("LozengeFlyProvider fly-out", () => { agent: { agent_name: "coder-1", model: "haiku", status: "completed" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -411,6 +418,7 @@ describe("LozengeFlyProvider fly-out", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -445,6 +453,7 @@ describe("AgentLozenge idle vs active appearance", () => { agent: { agent_name: "coder-1", model: null, status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ]; const { container } = render( @@ -471,6 +480,7 @@ describe("AgentLozenge idle vs active appearance", () => { agent: { agent_name: "coder-1", model: null, status: "pending" }, review_hold: null, qa: null, + depends_on: null, }, ]; const { container } = render( @@ -497,6 +507,7 @@ describe("AgentLozenge idle vs active appearance", () => { agent: { agent_name: "coder-1", model: null, status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ]; const { container } = render( @@ -550,6 +561,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => { agent: { agent_name: "coder-1", model: null, status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -573,6 +585,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -597,6 +610,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => { agent: { agent_name: "coder-1", model: null, status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -659,6 +673,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () agent: { agent_name: "coder-1", model: null, status: "completed" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -672,6 +687,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () agent: null, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -716,6 +732,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () agent: { agent_name: "coder-1", model: null, status: "completed" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -729,6 +746,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () agent: null, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -804,6 +822,7 @@ describe("LozengeFlyProvider agent swap (name change)", () => { agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -817,6 +836,7 @@ describe("LozengeFlyProvider agent swap (name change)", () => { agent: { agent_name: "coder-2", model: "haiku", status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -903,6 +923,7 @@ describe("LozengeFlyProvider fly-out without roster element", () => { }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -916,6 +937,7 @@ describe("LozengeFlyProvider fly-out without roster element", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -989,6 +1011,7 @@ describe("FlyingLozengeClone initial non-flying render", () => { agent: { agent_name: "coder-1", model: null, status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -1066,6 +1089,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -1079,6 +1103,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () agent: { agent_name: "coder-2", model: "haiku", status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -1147,6 +1172,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () agent: { agent_name: "coder-1", model: null, status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -1160,6 +1186,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () agent: { agent_name: "coder-2", model: null, status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); @@ -1247,6 +1274,7 @@ describe("Bug 137: animations remain functional through sustained agent activity agent: { agent_name: agentName, model: null, status: "running" }, review_hold: null, qa: null, + depends_on: null, }, ], }); diff --git a/frontend/src/components/StagePanel.test.tsx b/frontend/src/components/StagePanel.test.tsx index 36e4f91a..db3a83d7 100644 --- a/frontend/src/components/StagePanel.test.tsx +++ b/frontend/src/components/StagePanel.test.tsx @@ -19,6 +19,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -41,6 +42,7 @@ describe("StagePanel", () => { }, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -62,6 +64,7 @@ describe("StagePanel", () => { }, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -82,6 +85,7 @@ describe("StagePanel", () => { }, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -98,6 +102,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -114,6 +119,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -130,6 +136,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -148,6 +155,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -166,6 +174,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -184,6 +193,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -202,6 +212,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -223,6 +234,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -241,6 +253,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -259,6 +272,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -277,6 +291,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); @@ -298,6 +313,7 @@ describe("StagePanel", () => { agent: null, review_hold: null, qa: null, + depends_on: null, }, ]; render(); diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index d92a0fac..14e06111 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -420,6 +420,19 @@ export function StagePanel({ {item.merge_failure} )} + {item.depends_on && item.depends_on.length > 0 && ( +
+ Depends on:{" "} + {item.depends_on.map((n) => `#${n}`).join(", ")} +
+ )} {item.agent && ( = deps.iter().map(|n| format!("#{n}")).collect(); + fields.push(format!("**depends_on:** {}", nums.join(", "))); + } if !fields.is_empty() { out.push_str("**Front matter:**\n"); for f in &fields { @@ -445,6 +451,23 @@ mod tests { ); } + #[test] + fn whatsup_shows_depends_on_field() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "55_story_depends_story.md", + "---\nname: Depends Story\ndepends_on: [477, 478]\n---\n", + ); + let output = status_triage_cmd(tmp.path(), "55").unwrap(); + assert!( + output.contains("depends_on") || output.contains("#477"), + "should show depends_on field: {output}" + ); + assert!(output.contains("478"), "should list all dependency numbers: {output}"); + } + #[test] fn whatsup_no_worktree_shows_not_created() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/server/src/http/workflow/mod.rs b/server/src/http/workflow/mod.rs index cf64a0f5..952bb10e 100644 --- a/server/src/http/workflow/mod.rs +++ b/server/src/http/workflow/mod.rs @@ -50,6 +50,9 @@ pub struct UpcomingStory { /// True when the story has exceeded its retry limit and will not be auto-assigned. #[serde(skip_serializing_if = "Option::is_none")] pub blocked: Option, + /// Story numbers this story depends on. + #[serde(skip_serializing_if = "Option::is_none")] + pub depends_on: Option>, } pub struct StoryValidationResult { @@ -143,12 +146,12 @@ fn load_stage_items( .to_string(); let contents = fs::read_to_string(&path) .map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?; - let (name, error, merge_failure, review_hold, qa, retry_count, blocked) = match parse_front_matter(&contents) { - Ok(meta) => (meta.name, None, meta.merge_failure, meta.review_hold, meta.qa.map(|m| m.as_str().to_string()), meta.retry_count, meta.blocked), - Err(e) => (None, Some(e.to_string()), None, None, None, None, None), + let (name, error, merge_failure, review_hold, qa, retry_count, blocked, depends_on) = match parse_front_matter(&contents) { + Ok(meta) => (meta.name, None, meta.merge_failure, meta.review_hold, meta.qa.map(|m| m.as_str().to_string()), meta.retry_count, meta.blocked, meta.depends_on), + Err(e) => (None, Some(e.to_string()), None, None, None, None, None, None), }; let agent = agent_map.get(&story_id).cloned(); - stories.push(UpcomingStory { story_id, name, error, merge_failure, agent, review_hold, qa, retry_count, blocked }); + stories.push(UpcomingStory { story_id, name, error, merge_failure, agent, review_hold, qa, retry_count, blocked, depends_on }); } stories.sort_by(|a, b| a.story_id.cmp(&b.story_id)); @@ -526,6 +529,32 @@ mod tests { assert_eq!(item.agent.as_ref().unwrap().status, "pending"); } + #[test] + fn pipeline_state_includes_depends_on() { + let tmp = tempfile::tempdir().unwrap(); + let backlog = tmp.path().join(".huskies/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + fs::write( + backlog.join("20_story_dependent.md"), + "---\nname: Dependent Story\ndepends_on: [10, 11]\n---\n", + ) + .unwrap(); + fs::write( + backlog.join("21_story_independent.md"), + "---\nname: Independent Story\n---\n", + ) + .unwrap(); + + let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); + let state = load_pipeline_state(&ctx).unwrap(); + + let dependent = state.backlog.iter().find(|s| s.story_id == "20_story_dependent").unwrap(); + assert_eq!(dependent.depends_on, Some(vec![10, 11])); + + let independent = state.backlog.iter().find(|s| s.story_id == "21_story_independent").unwrap(); + assert_eq!(independent.depends_on, None); + } + #[test] fn load_upcoming_parses_metadata() { let tmp = tempfile::tempdir().unwrap(); diff --git a/server/src/http/ws.rs b/server/src/http/ws.rs index cab0e272..750747ad 100644 --- a/server/src/http/ws.rs +++ b/server/src/http/ws.rs @@ -795,6 +795,7 @@ mod tests { qa: None, retry_count: None, blocked: None, + depends_on: None, }; let resp = WsResponse::PipelineState { backlog: vec![story], @@ -937,6 +938,7 @@ mod tests { qa: None, retry_count: None, blocked: None, + depends_on: None, }], current: vec![UpcomingStory { story_id: "2_story_b".to_string(), @@ -948,6 +950,7 @@ mod tests { qa: None, retry_count: None, blocked: None, + depends_on: None, }], qa: vec![], merge: vec![], @@ -961,6 +964,7 @@ mod tests { qa: None, retry_count: None, blocked: None, + depends_on: None, }], }; let resp: WsResponse = state.into(); @@ -1121,6 +1125,7 @@ mod tests { qa: None, retry_count: None, blocked: None, + depends_on: None, }], qa: vec![], merge: vec![],