From 32e1f0d342f23c691a1f5c21dcf2f83184e35c7b Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 23 Feb 2026 14:27:15 +0000 Subject: [PATCH] story-kit: start 73_story_fade_out_completed_agents --- .../73_story_fade_out_completed_agents.md | 25 +++ frontend/src/App.css | 10 + frontend/src/components/AgentPanel.test.tsx | 206 +++++++++++++++++- frontend/src/components/AgentPanel.tsx | 84 ++++++- 4 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 .story_kit/work/2_current/73_story_fade_out_completed_agents.md 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]) => (