story-kit: start 59_story_current_work_panel
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user