From 9417ada89dee861e2e4ad26a46ab0a76adca4602 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 23 Feb 2026 13:23:35 +0000 Subject: [PATCH] story-kit: start 59_story_current_work_panel --- .../2_current/59_story_current_work_panel.md | 25 ++++ frontend/src/App.css | 30 ++++ frontend/src/api/client.ts | 7 + frontend/src/components/AgentPanel.tsx | 88 +++++++++-- frontend/src/components/StagePanel.test.tsx | 104 +++++++++++++ frontend/src/components/StagePanel.tsx | 68 ++++++++- server/src/http/workflow.rs | 141 +++++++++++++++++- 7 files changed, 440 insertions(+), 23 deletions(-) create mode 100644 .story_kit/work/2_current/59_story_current_work_panel.md create mode 100644 frontend/src/components/StagePanel.test.tsx diff --git a/.story_kit/work/2_current/59_story_current_work_panel.md b/.story_kit/work/2_current/59_story_current_work_panel.md new file mode 100644 index 0000000..9fbad2d --- /dev/null +++ b/.story_kit/work/2_current/59_story_current_work_panel.md @@ -0,0 +1,25 @@ +--- +name: Animated Agent Work Assignment UI +test_plan: pending +--- + +# Story 59: Animated Agent Work Assignment UI + +## User Story + +As a user watching the web UI, I want to see which agent is working on which work item across all active pipeline stages, with agents visually animating between idle and assigned states, so the pipeline feels like a living system I can watch in real time. + +## Acceptance Criteria + +- [ ] Work items in current/qa/merge panels each show which agent (if any) is working on them +- [ ] Agent lozenges (e.g. "coder-1 sonnet") visually animate from the agents panel to the work item they are assigned to +- [ ] When an agent completes, its lozenge animates back to idle in the agents panel (or to the next work item if immediately reassigned) +- [ ] Idle agents are visible in the agents panel with an idle state indicator +- [ ] Active agents show a subtle activity indicator (pulse, shimmer, etc.) on the work item they are docked to +- [ ] Pipeline state and agent assignments update in real time via WebSocket — no manual refresh needed +- [ ] The backend exposes agent-to-story assignments as part of the pipeline state (agent pool already tracks this) + +## Out of Scope + +- Actions from the UI (stop agent, reassign, start agent) — future story +- Agent output streaming in the work item card — existing agent panel handles this diff --git a/frontend/src/App.css b/frontend/src/App.css index a071cc6..d844a55 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -196,3 +196,33 @@ body, margin: 0; overflow: hidden; } + +/* Agent activity indicator pulse */ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.4; + transform: scale(0.85); + } +} + +/* Agent lozenge appearance animation (simulates arriving from agents panel) */ +@keyframes agentAppear { + from { + opacity: 0; + transform: translateY(-4px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Thinking/loading pulse for text */ +.pulse { + animation: pulse 1.5s infinite; +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 06782e3..31e5056 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -8,10 +8,17 @@ export type WsRequest = type: "cancel"; }; +export interface AgentAssignment { + agent_name: string; + model: string | null; + status: string; +} + export interface PipelineStageItem { story_id: string; name: string | null; error: string | null; + agent: AgentAssignment | null; } export interface PipelineState { diff --git a/frontend/src/components/AgentPanel.tsx b/frontend/src/components/AgentPanel.tsx index 6ca3ce3..996b994 100644 --- a/frontend/src/components/AgentPanel.tsx +++ b/frontend/src/components/AgentPanel.tsx @@ -73,7 +73,16 @@ function StatusBadge({ status }: { status: AgentStatusValue }) { ); } -function RosterBadge({ agent }: { agent: AgentConfigInfo }) { +function RosterBadge({ + agent, + activeStoryId, +}: { + agent: AgentConfigInfo; + activeStoryId: string | null; +}) { + const isActive = activeStoryId !== null; + const storyNumber = activeStoryId?.match(/^(\d+)/)?.[1]; + return ( - {agent.name} - {agent.model && {agent.model}} + {isActive && ( + + )} + {!isActive && ( + + )} + + {agent.name} + + {agent.model && ( + + {agent.model} + + )} + {isActive && storyNumber && ( + + #{storyNumber} + + )} + {!isActive && ( + idle + )} ); } @@ -519,7 +570,7 @@ export function AgentPanel() { )} - {/* Roster badges */} + {/* Roster badges — show all configured agents with idle/active state */} {roster.length > 0 && (
- {roster.map((a) => ( - - ))} + {roster.map((a) => { + // Find the story this roster agent is currently working on (if any) + const activeEntry = Object.entries(agents).find( + ([, state]) => + state.agentName === a.name && + (state.status === "running" || state.status === "pending"), + ); + const activeStoryId = activeEntry + ? activeEntry[0].split(":")[0] + : null; + return ( + + ); + })}
)} diff --git a/frontend/src/components/StagePanel.test.tsx b/frontend/src/components/StagePanel.test.tsx new file mode 100644 index 0000000..4708d8f --- /dev/null +++ b/frontend/src/components/StagePanel.test.tsx @@ -0,0 +1,104 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { PipelineStageItem } from "../api/client"; +import { StagePanel } from "./StagePanel"; + +describe("StagePanel", () => { + it("renders empty message when no items", () => { + render(); + expect(screen.getByText("Empty.")).toBeInTheDocument(); + }); + + it("renders story item without agent lozenge when agent is null", () => { + const items: PipelineStageItem[] = [ + { + story_id: "42_story_no_agent", + name: "No Agent Story", + error: null, + agent: null, + }, + ]; + render(); + expect(screen.getByText("No Agent Story")).toBeInTheDocument(); + // No agent lozenge + expect(screen.queryByText(/coder-/)).not.toBeInTheDocument(); + }); + + it("shows agent lozenge with agent name and model when agent is running", () => { + const items: PipelineStageItem[] = [ + { + story_id: "43_story_with_agent", + name: "Active Story", + error: null, + agent: { + agent_name: "coder-1", + model: "sonnet", + status: "running", + }, + }, + ]; + render(); + expect(screen.getByText("Active Story")).toBeInTheDocument(); + expect(screen.getByText("coder-1 sonnet")).toBeInTheDocument(); + }); + + it("shows agent lozenge with only agent name when model is null", () => { + const items: PipelineStageItem[] = [ + { + story_id: "44_story_no_model", + name: "No Model Story", + error: null, + agent: { + agent_name: "coder-2", + model: null, + status: "running", + }, + }, + ]; + render(); + expect(screen.getByText("coder-2")).toBeInTheDocument(); + }); + + it("shows agent lozenge for pending agent", () => { + const items: PipelineStageItem[] = [ + { + story_id: "45_story_pending", + name: "Pending Story", + error: null, + agent: { + agent_name: "coder-1", + model: "haiku", + status: "pending", + }, + }, + ]; + render(); + expect(screen.getByText("coder-1 haiku")).toBeInTheDocument(); + }); + + it("shows story number extracted from story_id", () => { + const items: PipelineStageItem[] = [ + { + story_id: "59_story_current_work_panel", + name: "Current Work Panel", + error: null, + agent: null, + }, + ]; + render(); + expect(screen.getByText("#59")).toBeInTheDocument(); + }); + + it("shows error message when item has an error", () => { + const items: PipelineStageItem[] = [ + { + story_id: "1_story_bad", + name: null, + error: "Missing front matter", + agent: null, + }, + ]; + render(); + expect(screen.getByText("Missing front matter")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index 7b5edf0..62780bb 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -1,4 +1,4 @@ -import type { PipelineStageItem } from "../api/client"; +import type { AgentAssignment, PipelineStageItem } from "../api/client"; interface StagePanelProps { title: string; @@ -6,6 +6,61 @@ interface StagePanelProps { emptyMessage?: string; } +function AgentLozenge({ agent }: { agent: AgentAssignment }) { + const isRunning = agent.status === "running"; + const isPending = agent.status === "pending"; + const color = isRunning ? "#58a6ff" : isPending ? "#e3b341" : "#aaa"; + const label = agent.model + ? `${agent.agent_name} ${agent.model}` + : agent.agent_name; + + return ( +
+ {isRunning && ( + + )} + {isPending && ( + + )} + {label} +
+ ); +} + export function StagePanel({ title, items, @@ -58,13 +113,15 @@ export function StagePanel({
@@ -94,6 +151,7 @@ export function StagePanel({
)}
+ {item.agent && } ); })} diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index 8535d6d..6cc34cc 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -1,14 +1,26 @@ +use crate::agents::AgentStatus; use crate::http::context::AppContext; use crate::io::story_metadata::parse_front_matter; use serde::Serialize; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +/// Agent assignment embedded in a pipeline stage item. +#[derive(Clone, Debug, Serialize)] +pub struct AgentAssignment { + pub agent_name: String, + pub model: Option, + pub status: String, +} + #[derive(Clone, Debug, Serialize)] pub struct UpcomingStory { pub story_id: String, pub name: Option, pub error: Option, + /// Active agent working on this item, if any. + pub agent: Option, } pub struct StoryValidationResult { @@ -28,16 +40,55 @@ pub struct PipelineState { /// Load the full pipeline state (all 4 active stages). pub fn load_pipeline_state(ctx: &AppContext) -> Result { + let agent_map = build_active_agent_map(ctx); Ok(PipelineState { - upcoming: load_stage_items(ctx, "1_upcoming")?, - current: load_stage_items(ctx, "2_current")?, - qa: load_stage_items(ctx, "3_qa")?, - merge: load_stage_items(ctx, "4_merge")?, + upcoming: load_stage_items(ctx, "1_upcoming", &HashMap::new())?, + current: load_stage_items(ctx, "2_current", &agent_map)?, + qa: load_stage_items(ctx, "3_qa", &agent_map)?, + merge: load_stage_items(ctx, "4_merge", &agent_map)?, }) } +/// Build a map from story_id → AgentAssignment for all pending/running agents. +fn build_active_agent_map(ctx: &AppContext) -> HashMap { + let agents = match ctx.agents.list_agents() { + Ok(a) => a, + Err(_) => return HashMap::new(), + }; + + let config_opt = ctx + .state + .get_project_root() + .ok() + .and_then(|root| crate::config::ProjectConfig::load(&root).ok()); + + let mut map = HashMap::new(); + for agent in agents { + if !matches!(agent.status, AgentStatus::Pending | AgentStatus::Running) { + continue; + } + let model = config_opt + .as_ref() + .and_then(|cfg| cfg.find_agent(&agent.agent_name)) + .and_then(|ac| ac.model.clone()); + map.insert( + agent.story_id.clone(), + AgentAssignment { + agent_name: agent.agent_name, + model, + status: agent.status.to_string(), + }, + ); + } + map +} + /// Load work items from any pipeline stage directory. -fn load_stage_items(ctx: &AppContext, stage_dir: &str) -> Result, String> { +fn load_stage_items( + ctx: &AppContext, + stage_dir: &str, + agent_map: &HashMap, +) -> Result, String> { let root = ctx.state.get_project_root()?; let dir = root.join(".story_kit").join("work").join(stage_dir); @@ -65,7 +116,8 @@ fn load_stage_items(ctx: &AppContext, stage_dir: &str) -> Result (meta.name, None), Err(e) => (None, Some(e.to_string())), }; - stories.push(UpcomingStory { story_id, name, error }); + let agent = agent_map.get(&story_id).cloned(); + stories.push(UpcomingStory { story_id, name, error, agent }); } stories.sort_by(|a, b| a.story_id.cmp(&b.story_id)); @@ -73,7 +125,7 @@ fn load_stage_items(ctx: &AppContext, stage_dir: &str) -> Result Result, String> { - load_stage_items(ctx, "1_upcoming") + load_stage_items(ctx, "1_upcoming", &HashMap::new()) } /// Shared create-story logic used by both the OpenApi and MCP handlers. @@ -546,6 +598,81 @@ mod tests { assert!(result.is_empty()); } + #[test] + fn pipeline_state_includes_agent_for_running_story() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().to_path_buf(); + + let current = root.join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + fs::write( + current.join("10_story_test.md"), + "---\nname: Test Story\ntest_plan: approved\n---\n# Story\n", + ) + .unwrap(); + + let ctx = crate::http::context::AppContext::new_test(root); + ctx.agents.inject_test_agent("10_story_test", "coder-1", crate::agents::AgentStatus::Running); + + let state = load_pipeline_state(&ctx).unwrap(); + + assert_eq!(state.current.len(), 1); + let item = &state.current[0]; + assert!(item.agent.is_some(), "running agent should appear on work item"); + let agent = item.agent.as_ref().unwrap(); + assert_eq!(agent.agent_name, "coder-1"); + assert_eq!(agent.status, "running"); + } + + #[test] + fn pipeline_state_no_agent_for_completed_story() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().to_path_buf(); + + let current = root.join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + fs::write( + current.join("11_story_done.md"), + "---\nname: Done Story\ntest_plan: approved\n---\n# Story\n", + ) + .unwrap(); + + let ctx = crate::http::context::AppContext::new_test(root); + ctx.agents.inject_test_agent("11_story_done", "coder-1", crate::agents::AgentStatus::Completed); + + let state = load_pipeline_state(&ctx).unwrap(); + + assert_eq!(state.current.len(), 1); + assert!( + state.current[0].agent.is_none(), + "completed agent should not appear on work item" + ); + } + + #[test] + fn pipeline_state_pending_agent_included() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().to_path_buf(); + + let current = root.join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + fs::write( + current.join("12_story_pending.md"), + "---\nname: Pending Story\ntest_plan: approved\n---\n# Story\n", + ) + .unwrap(); + + let ctx = crate::http::context::AppContext::new_test(root); + ctx.agents.inject_test_agent("12_story_pending", "coder-1", crate::agents::AgentStatus::Pending); + + let state = load_pipeline_state(&ctx).unwrap(); + + assert_eq!(state.current.len(), 1); + let item = &state.current[0]; + assert!(item.agent.is_some(), "pending agent should appear on work item"); + assert_eq!(item.agent.as_ref().unwrap().status, "pending"); + } + #[test] fn load_upcoming_parses_metadata() { let tmp = tempfile::tempdir().unwrap();