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();