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