diff --git a/.story_kit/work/2_current/73_story_fade_out_completed_agents.md b/.story_kit/work/2_current/73_story_fade_out_completed_agents.md
new file mode 100644
index 0000000..980955b
--- /dev/null
+++ b/.story_kit/work/2_current/73_story_fade_out_completed_agents.md
@@ -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
diff --git a/frontend/src/App.css b/frontend/src/App.css
index d844a55..d990c0c 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -226,3 +226,13 @@ body,
.pulse {
animation: pulse 1.5s infinite;
}
+
+/* Agent entry fade-out for completed/failed agents */
+@keyframes agentFadeOut {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
diff --git a/frontend/src/components/AgentPanel.test.tsx b/frontend/src/components/AgentPanel.test.tsx
index c9cf4e1..fc57b9e 100644
--- a/frontend/src/components/AgentPanel.test.tsx
+++ b/frontend/src/components/AgentPanel.test.tsx
@@ -1,6 +1,6 @@
import { render, screen, waitFor } from "@testing-library/react";
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 { agentsApi } from "../api/agents";
@@ -156,3 +156,207 @@ describe("AgentPanel diff command", () => {
).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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+ });
+ });
+ });
+});
diff --git a/frontend/src/components/AgentPanel.tsx b/frontend/src/components/AgentPanel.tsx
index 996b994..48a549e 100644
--- a/frontend/src/components/AgentPanel.tsx
+++ b/frontend/src/components/AgentPanel.tsx
@@ -16,6 +16,7 @@ interface AgentState {
sessionId: string | null;
worktreePath: string | null;
baseBranch: string | null;
+ terminalAt: number | null;
}
const STATUS_COLORS: Record = {
@@ -283,6 +284,8 @@ export function EditorCommand({
);
}
+const FADE_DURATION_MS = 60_000;
+
export function AgentPanel() {
const [agents, setAgents] = useState>({});
const [roster, setRoster] = useState([]);
@@ -294,6 +297,10 @@ export function AgentPanel() {
const [editingEditor, setEditingEditor] = useState(false);
const cleanupRefs = useRef void>>({});
const logEndRefs = useRef>({});
+ // Refs for fade-out timers (pause/resume on expand/collapse)
+ const fadeTimerRef = useRef>>({});
+ const fadeElapsedRef = useRef>({});
+ const fadeTimerStartRef = useRef>({});
// Load roster, existing agents, and editor preference on mount
useEffect(() => {
@@ -306,8 +313,11 @@ export function AgentPanel() {
.listAgents()
.then((agentList) => {
const agentMap: Record = {};
+ const now = Date.now();
for (const a of agentList) {
const key = agentKey(a.story_id, a.agent_name);
+ const isTerminal =
+ a.status === "completed" || a.status === "failed";
agentMap[key] = {
agentName: a.agent_name,
status: a.status,
@@ -315,6 +325,7 @@ export function AgentPanel() {
sessionId: a.session_id,
worktreePath: a.worktree_path,
baseBranch: a.base_branch,
+ terminalAt: isTerminal ? now : null,
};
if (a.status === "running" || a.status === "pending") {
subscribeToAgent(a.story_id, a.agent_name);
@@ -357,17 +368,26 @@ export function AgentPanel() {
sessionId: null,
worktreePath: null,
baseBranch: null,
+ terminalAt: null,
};
switch (event.type) {
- case "status":
+ case "status": {
+ const newStatus =
+ (event.status as AgentStatusValue) ?? current.status;
+ const isTerminal =
+ newStatus === "completed" || newStatus === "failed";
return {
...prev,
[key]: {
...current,
- status: (event.status as AgentStatusValue) ?? current.status,
+ status: newStatus,
+ terminalAt: isTerminal
+ ? (current.terminalAt ?? Date.now())
+ : current.terminalAt,
},
};
+ }
case "output":
return {
...prev,
@@ -383,6 +403,7 @@ export function AgentPanel() {
...current,
status: "completed",
sessionId: event.session_id ?? current.sessionId,
+ terminalAt: current.terminalAt ?? Date.now(),
},
};
case "error":
@@ -395,6 +416,7 @@ export function AgentPanel() {
...current.log,
`[ERROR] ${event.message ?? "Unknown error"}`,
],
+ terminalAt: current.terminalAt ?? Date.now(),
},
};
default:
@@ -418,6 +440,53 @@ export function AgentPanel() {
}
}, [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) => {
setActionError(null);
const key = agentKey(storyId, agentName);
@@ -626,11 +695,22 @@ export function AgentPanel() {
{Object.entries(agents).map(([key, a]) => (