story-kit: start 59_story_current_work_panel
This commit is contained in:
25
.story_kit/work/2_current/59_story_current_work_panel.md
Normal file
25
.story_kit/work/2_current/59_story_current_work_panel.md
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
104
frontend/src/components/StagePanel.test.tsx
Normal file
104
frontend/src/components/StagePanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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(¤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();
|
||||
|
||||
Reference in New Issue
Block a user