story-kit: start 59_story_current_work_panel

This commit is contained in:
Dave
2026-02-23 13:23:35 +00:00
parent 765b537dc0
commit 9417ada89d
7 changed files with 440 additions and 23 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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 (
<span
style={{
@@ -83,14 +92,56 @@ function RosterBadge({ agent }: { agent: AgentConfigInfo }) {
padding: "2px 8px",
borderRadius: "6px",
fontSize: "0.7em",
background: "#ffffff08",
color: "#888",
border: "1px solid #333",
background: isActive ? "#58a6ff18" : "#ffffff08",
color: isActive ? "#58a6ff" : "#888",
border: isActive ? "1px solid #58a6ff44" : "1px solid #333",
transition: "background 0.3s, color 0.3s, border-color 0.3s",
}}
title={agent.role || agent.name}
title={
isActive
? `Working on #${storyNumber ?? activeStoryId}`
: `${agent.role || agent.name} — idle`
}
>
<span style={{ fontWeight: 600, color: "#aaa" }}>{agent.name}</span>
{agent.model && <span style={{ color: "#666" }}>{agent.model}</span>}
{isActive && (
<span
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
background: "#58a6ff",
animation: "pulse 1.5s infinite",
flexShrink: 0,
}}
/>
)}
{!isActive && (
<span
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
background: "#555",
flexShrink: 0,
}}
/>
)}
<span style={{ fontWeight: 600, color: isActive ? "#58a6ff" : "#aaa" }}>
{agent.name}
</span>
{agent.model && (
<span style={{ color: isActive ? "#7ab8ff" : "#666" }}>
{agent.model}
</span>
)}
{isActive && storyNumber && (
<span style={{ color: "#7ab8ff", marginLeft: "2px" }}>
#{storyNumber}
</span>
)}
{!isActive && (
<span style={{ color: "#444", fontStyle: "italic" }}>idle</span>
)}
</span>
);
}
@@ -519,7 +570,7 @@ export function AgentPanel() {
)}
</div>
{/* Roster badges */}
{/* Roster badges — show all configured agents with idle/active state */}
{roster.length > 0 && (
<div
style={{
@@ -528,9 +579,24 @@ export function AgentPanel() {
gap: "4px",
}}
>
{roster.map((a) => (
<RosterBadge key={`roster-${a.name}`} agent={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 (
<RosterBadge
key={`roster-${a.name}`}
agent={a}
activeStoryId={activeStoryId}
/>
);
})}
</div>
)}

View File

@@ -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(<StagePanel title="Current" items={[]} />);
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(<StagePanel title="Current" items={items} />);
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(<StagePanel title="Current" items={items} />);
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(<StagePanel title="Current" items={items} />);
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(<StagePanel title="QA" items={items} />);
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(<StagePanel title="Current" items={items} />);
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(<StagePanel title="Upcoming" items={items} />);
expect(screen.getByText("Missing front matter")).toBeInTheDocument();
});
});

View File

@@ -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 (
<div
className="agent-lozenge"
style={{
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: "2px 8px",
borderRadius: "999px",
fontSize: "0.72em",
fontWeight: 600,
background: `${color}18`,
color,
border: `1px solid ${color}44`,
marginTop: "4px",
animation: "agentAppear 0.3s ease-out",
}}
>
{isRunning && (
<span
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
background: color,
animation: "pulse 1.5s infinite",
flexShrink: 0,
}}
/>
)}
{isPending && (
<span
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
background: color,
opacity: 0.7,
flexShrink: 0,
}}
/>
)}
{label}
</div>
);
}
export function StagePanel({
title,
items,
@@ -58,13 +113,15 @@ export function StagePanel({
<div
key={`${title}-${item.story_id}`}
style={{
border: "1px solid #2a2a2a",
border: item.agent
? "1px solid #2a3a4a"
: "1px solid #2a2a2a",
borderRadius: "8px",
padding: "8px 12px",
background: "#191919",
background: item.agent ? "#161e2a" : "#191919",
display: "flex",
alignItems: "center",
gap: "8px",
flexDirection: "column",
gap: "2px",
}}
>
<div style={{ flex: 1 }}>
@@ -94,6 +151,7 @@ export function StagePanel({
</div>
)}
</div>
{item.agent && <AgentLozenge agent={item.agent} />}
</div>
);
})}

View File

@@ -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<String>,
pub status: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct UpcomingStory {
pub story_id: String,
pub name: Option<String>,
pub error: Option<String>,
/// Active agent working on this item, if any.
pub agent: Option<AgentAssignment>,
}
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<PipelineState, String> {
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<String, AgentAssignment> {
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<Vec<UpcomingStory>, String> {
fn load_stage_items(
ctx: &AppContext,
stage_dir: &str,
agent_map: &HashMap<String, AgentAssignment>,
) -> Result<Vec<UpcomingStory>, 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<Vec<UpcomingSto
Ok(meta) => (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<Vec<UpcomingSto
}
pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, 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(&current).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(&current).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(&current).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();