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

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