story-kit: start 73_story_fade_out_completed_agents

This commit is contained in:
Dave
2026-02-23 14:27:15 +00:00
parent 6297d1643e
commit 32e1f0d342
4 changed files with 322 additions and 3 deletions

View File

@@ -0,0 +1,25 @@
---
name: Fade Out Completed Agents in Panel
test_plan: pending
---
# "Story 70: Fade Out Completed Agents in Panel"
## User Story
As a user, I want completed agent entries in the Agents panel to gradually fade out and disappear after about 60 seconds, so that the panel doesn't fill up with stale results.
## Acceptance Criteria
- [ ] When an agent reaches a terminal state (completed or failed), a 60-second countdown begins
- [ ] During the countdown the entry fades from full opacity to zero using a CSS transition
- [ ] After the fade completes, the entry is removed from the panel DOM
- [ ] Running and pending agents are never faded or removed
- [ ] If the user expands a fading entry (clicks the triangle), the fade pauses so they can read the output
- [ ] Collapsing the entry resumes the fade from where it left off
## Out of Scope
- Persisting completed agent history across page refreshes
- A "show all completed" toggle to bring back faded entries
- Configurable fade duration

View File

@@ -226,3 +226,13 @@ body,
.pulse { .pulse {
animation: pulse 1.5s infinite; animation: pulse 1.5s infinite;
} }
/* Agent entry fade-out for completed/failed agents */
@keyframes agentFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -1,6 +1,6 @@
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentConfigInfo, AgentInfo } from "../api/agents"; import type { AgentConfigInfo, AgentInfo } from "../api/agents";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
@@ -156,3 +156,207 @@ describe("AgentPanel diff command", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
describe("AgentPanel fade-out", () => {
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});
beforeEach(() => {
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
mockedAgents.listAgents.mockResolvedValue([]);
});
it("applies fade animation to a completed agent", async () => {
const agentList: AgentInfo[] = [
{
story_id: "73_fade_test",
agent_name: "coder-1",
status: "completed",
session_id: null,
worktree_path: null,
base_branch: null,
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
const { container } = render(<AgentPanel />);
const entry = await waitFor(() => {
const el = container.querySelector(
'[data-testid="agent-entry-73_fade_test:coder-1"]',
);
expect(el).toBeInTheDocument();
return el as HTMLElement;
});
expect(entry.style.animationName).toBe("agentFadeOut");
});
it("applies fade animation to a failed agent", async () => {
const agentList: AgentInfo[] = [
{
story_id: "73_fade_fail",
agent_name: "coder-1",
status: "failed",
session_id: null,
worktree_path: null,
base_branch: null,
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
const { container } = render(<AgentPanel />);
const entry = await waitFor(() => {
const el = container.querySelector(
'[data-testid="agent-entry-73_fade_fail:coder-1"]',
);
expect(el).toBeInTheDocument();
return el as HTMLElement;
});
expect(entry.style.animationName).toBe("agentFadeOut");
});
it("does not apply fade animation to a running agent", async () => {
const agentList: AgentInfo[] = [
{
story_id: "73_running",
agent_name: "coder-1",
status: "running",
session_id: null,
worktree_path: null,
base_branch: null,
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
const { container } = render(<AgentPanel />);
const entry = await waitFor(() => {
const el = container.querySelector(
'[data-testid="agent-entry-73_running:coder-1"]',
);
expect(el).toBeInTheDocument();
return el as HTMLElement;
});
expect(entry.style.animationName).not.toBe("agentFadeOut");
});
it("pauses the fade when the entry is expanded", async () => {
const agentList: AgentInfo[] = [
{
story_id: "73_pause_test",
agent_name: "coder-1",
status: "completed",
session_id: null,
worktree_path: null,
base_branch: null,
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
const { container } = render(<AgentPanel />);
const entry = await waitFor(() => {
const el = container.querySelector(
'[data-testid="agent-entry-73_pause_test:coder-1"]',
);
expect(el).toBeInTheDocument();
return el as HTMLElement;
});
// Initially running (not paused)
expect(entry.style.animationPlayState).toBe("running");
// Expand the agent
const expandButton = screen.getByRole("button", { name: "▶" });
await userEvent.click(expandButton);
// Animation should be paused
expect(entry.style.animationPlayState).toBe("paused");
});
it("resumes the fade when the entry is collapsed", async () => {
const agentList: AgentInfo[] = [
{
story_id: "73_resume_test",
agent_name: "coder-1",
status: "completed",
session_id: null,
worktree_path: null,
base_branch: null,
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
const { container } = render(<AgentPanel />);
const entry = await waitFor(() => {
const el = container.querySelector(
'[data-testid="agent-entry-73_resume_test:coder-1"]',
);
expect(el).toBeInTheDocument();
return el as HTMLElement;
});
const expandButton = screen.getByRole("button", { name: "▶" });
// Expand
await userEvent.click(expandButton);
expect(entry.style.animationPlayState).toBe("paused");
// Collapse
await userEvent.click(expandButton);
expect(entry.style.animationPlayState).toBe("running");
});
describe("removes the agent entry after 60s", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("removes the agent entry after the 60-second fade completes", async () => {
const agentList: AgentInfo[] = [
{
story_id: "73_remove_test",
agent_name: "coder-1",
status: "completed",
session_id: null,
worktree_path: null,
base_branch: null,
},
];
mockedAgents.listAgents.mockResolvedValue(agentList);
const { container } = render(<AgentPanel />);
// Wait for the agent entry to appear
await waitFor(() => {
expect(
container.querySelector(
'[data-testid="agent-entry-73_remove_test:coder-1"]',
),
).toBeInTheDocument();
});
// Advance timers by 60 seconds
vi.advanceTimersByTime(60_000);
// Entry should be removed
await waitFor(() => {
expect(
container.querySelector(
'[data-testid="agent-entry-73_remove_test:coder-1"]',
),
).not.toBeInTheDocument();
});
});
});
});

View File

@@ -16,6 +16,7 @@ interface AgentState {
sessionId: string | null; sessionId: string | null;
worktreePath: string | null; worktreePath: string | null;
baseBranch: string | null; baseBranch: string | null;
terminalAt: number | null;
} }
const STATUS_COLORS: Record<AgentStatusValue, string> = { const STATUS_COLORS: Record<AgentStatusValue, string> = {
@@ -283,6 +284,8 @@ export function EditorCommand({
); );
} }
const FADE_DURATION_MS = 60_000;
export function AgentPanel() { export function AgentPanel() {
const [agents, setAgents] = useState<Record<string, AgentState>>({}); const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [roster, setRoster] = useState<AgentConfigInfo[]>([]); const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
@@ -294,6 +297,10 @@ export function AgentPanel() {
const [editingEditor, setEditingEditor] = useState(false); const [editingEditor, setEditingEditor] = useState(false);
const cleanupRefs = useRef<Record<string, () => void>>({}); const cleanupRefs = useRef<Record<string, () => void>>({});
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({}); const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Refs for fade-out timers (pause/resume on expand/collapse)
const fadeTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const fadeElapsedRef = useRef<Record<string, number>>({});
const fadeTimerStartRef = useRef<Record<string, number | null>>({});
// Load roster, existing agents, and editor preference on mount // Load roster, existing agents, and editor preference on mount
useEffect(() => { useEffect(() => {
@@ -306,8 +313,11 @@ export function AgentPanel() {
.listAgents() .listAgents()
.then((agentList) => { .then((agentList) => {
const agentMap: Record<string, AgentState> = {}; const agentMap: Record<string, AgentState> = {};
const now = Date.now();
for (const a of agentList) { for (const a of agentList) {
const key = agentKey(a.story_id, a.agent_name); const key = agentKey(a.story_id, a.agent_name);
const isTerminal =
a.status === "completed" || a.status === "failed";
agentMap[key] = { agentMap[key] = {
agentName: a.agent_name, agentName: a.agent_name,
status: a.status, status: a.status,
@@ -315,6 +325,7 @@ export function AgentPanel() {
sessionId: a.session_id, sessionId: a.session_id,
worktreePath: a.worktree_path, worktreePath: a.worktree_path,
baseBranch: a.base_branch, baseBranch: a.base_branch,
terminalAt: isTerminal ? now : null,
}; };
if (a.status === "running" || a.status === "pending") { if (a.status === "running" || a.status === "pending") {
subscribeToAgent(a.story_id, a.agent_name); subscribeToAgent(a.story_id, a.agent_name);
@@ -357,17 +368,26 @@ export function AgentPanel() {
sessionId: null, sessionId: null,
worktreePath: null, worktreePath: null,
baseBranch: null, baseBranch: null,
terminalAt: null,
}; };
switch (event.type) { switch (event.type) {
case "status": case "status": {
const newStatus =
(event.status as AgentStatusValue) ?? current.status;
const isTerminal =
newStatus === "completed" || newStatus === "failed";
return { return {
...prev, ...prev,
[key]: { [key]: {
...current, ...current,
status: (event.status as AgentStatusValue) ?? current.status, status: newStatus,
terminalAt: isTerminal
? (current.terminalAt ?? Date.now())
: current.terminalAt,
}, },
}; };
}
case "output": case "output":
return { return {
...prev, ...prev,
@@ -383,6 +403,7 @@ export function AgentPanel() {
...current, ...current,
status: "completed", status: "completed",
sessionId: event.session_id ?? current.sessionId, sessionId: event.session_id ?? current.sessionId,
terminalAt: current.terminalAt ?? Date.now(),
}, },
}; };
case "error": case "error":
@@ -395,6 +416,7 @@ export function AgentPanel() {
...current.log, ...current.log,
`[ERROR] ${event.message ?? "Unknown error"}`, `[ERROR] ${event.message ?? "Unknown error"}`,
], ],
terminalAt: current.terminalAt ?? Date.now(),
}, },
}; };
default: default:
@@ -418,6 +440,53 @@ export function AgentPanel() {
} }
}, [expandedKey, agents]); }, [expandedKey, agents]);
// Manage fade-out timers for terminal agents.
// Timers are paused when an entry is expanded and resumed on collapse.
useEffect(() => {
for (const [key, agent] of Object.entries(agents)) {
if (!agent.terminalAt) continue;
const isExpanded = expandedKey === key;
const hasTimer = key in fadeTimerRef.current;
if (isExpanded && hasTimer) {
// Pause: clear timer and accumulate elapsed time
clearTimeout(fadeTimerRef.current[key]);
const started = fadeTimerStartRef.current[key];
if (started !== null && started !== undefined) {
fadeElapsedRef.current[key] =
(fadeElapsedRef.current[key] ?? 0) + (Date.now() - started);
}
delete fadeTimerRef.current[key];
fadeTimerStartRef.current[key] = null;
} else if (!isExpanded && !hasTimer) {
// Start or resume timer with remaining time
const elapsed = fadeElapsedRef.current[key] ?? 0;
const remaining = Math.max(0, FADE_DURATION_MS - elapsed);
fadeTimerStartRef.current[key] = Date.now();
fadeTimerRef.current[key] = setTimeout(() => {
setAgents((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
delete fadeTimerRef.current[key];
delete fadeTimerStartRef.current[key];
delete fadeElapsedRef.current[key];
}, remaining);
}
}
}, [agents, expandedKey]);
// Clean up fade timers on unmount
useEffect(() => {
return () => {
for (const timer of Object.values(fadeTimerRef.current)) {
clearTimeout(timer);
}
fadeTimerRef.current = {};
};
}, []);
const handleStop = async (storyId: string, agentName: string) => { const handleStop = async (storyId: string, agentName: string) => {
setActionError(null); setActionError(null);
const key = agentKey(storyId, agentName); const key = agentKey(storyId, agentName);
@@ -626,11 +695,22 @@ export function AgentPanel() {
{Object.entries(agents).map(([key, a]) => ( {Object.entries(agents).map(([key, a]) => (
<div <div
key={`agent-${key}`} key={`agent-${key}`}
data-testid={`agent-entry-${key}`}
style={{ style={{
border: "1px solid #2a2a2a", border: "1px solid #2a2a2a",
borderRadius: "8px", borderRadius: "8px",
background: "#191919", background: "#191919",
overflow: "hidden", overflow: "hidden",
...(a.terminalAt
? {
animationName: "agentFadeOut",
animationDuration: "60s",
animationTimingFunction: "linear",
animationFillMode: "forwards",
animationPlayState:
expandedKey === key ? "paused" : "running",
}
: {}),
}} }}
> >
<div <div