story-kit: start 83_story_remove_active_work_list_from_agents_panel
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Remove active work list from Agents panel"
|
||||||
|
test_plan: approved
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 83: Remove active work list from Agents panel
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user, I want the Agents panel to only show the agent roster (with lozenges) and not a separate list of active work items, since the flying lozenges already indicate which agents are working on which stories.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] The Agents panel no longer displays the list of active work items / agents-at-work section
|
||||||
|
- [ ] The agent roster with availability lozenges remains visible and functional
|
||||||
|
- [ ] Flying lozenge animations continue to work as before
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: "Remove active work list from Agents panel"
|
name: "Remove active work list from Agents panel"
|
||||||
test_plan: pending
|
test_plan: approved
|
||||||
---
|
---
|
||||||
|
|
||||||
# Story 83: Remove active work list from Agents panel
|
# Story 83: Remove active work list from Agents panel
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
import { render, screen } 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 type { AgentConfigInfo, AgentInfo } from "../api/agents";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
|
||||||
@@ -43,7 +34,7 @@ const ROSTER: AgentConfigInfo[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("AgentPanel diff command", () => {
|
describe("AgentPanel active work list removed", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Element.prototype.scrollIntoView = vi.fn();
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
});
|
});
|
||||||
@@ -53,325 +44,28 @@ describe("AgentPanel diff command", () => {
|
|||||||
mockedAgents.listAgents.mockResolvedValue([]);
|
mockedAgents.listAgents.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows diff command when an agent has a worktree path", async () => {
|
it("does not render active agent entries even when agents are running", async () => {
|
||||||
const agentList: AgentInfo[] = [
|
const agentList: AgentInfo[] = [
|
||||||
{
|
{
|
||||||
story_id: "33_diff_commands",
|
story_id: "83_active",
|
||||||
agent_name: "coder-1",
|
agent_name: "coder-1",
|
||||||
status: "running",
|
status: "running",
|
||||||
session_id: null,
|
session_id: null,
|
||||||
worktree_path: "/tmp/project-story-33",
|
worktree_path: "/tmp/wt",
|
||||||
base_branch: "master",
|
base_branch: "master",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||||
|
|
||||||
render(<AgentPanel />);
|
const { container } = render(<AgentPanel />);
|
||||||
|
|
||||||
// Expand the agent detail by clicking the expand button
|
// Roster badge should still be visible
|
||||||
const expandButton = await screen.findByText("▶");
|
await screen.findByTestId("roster-badge-coder-1");
|
||||||
await userEvent.click(expandButton);
|
|
||||||
|
|
||||||
// The diff command should be rendered
|
// No agent entry divs should exist
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(
|
container.querySelector('[data-testid^="agent-entry-"]'),
|
||||||
'cd "/tmp/project-story-33" && git difftool master...HEAD',
|
).not.toBeInTheDocument();
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// A copy button should exist
|
|
||||||
expect(screen.getByText("Copy")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("copies diff command to clipboard on click", async () => {
|
|
||||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
||||||
Object.assign(navigator, {
|
|
||||||
clipboard: { writeText },
|
|
||||||
});
|
|
||||||
|
|
||||||
const agentList: AgentInfo[] = [
|
|
||||||
{
|
|
||||||
story_id: "33_diff_commands",
|
|
||||||
agent_name: "coder-1",
|
|
||||||
status: "completed",
|
|
||||||
session_id: null,
|
|
||||||
worktree_path: "/home/user/my-project-story-33",
|
|
||||||
base_branch: "main",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
||||||
|
|
||||||
render(<AgentPanel />);
|
|
||||||
|
|
||||||
const expandButton = await screen.findByText("▶");
|
|
||||||
await userEvent.click(expandButton);
|
|
||||||
|
|
||||||
const copyButton = await screen.findByText("Copy");
|
|
||||||
await userEvent.click(copyButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(writeText).toHaveBeenCalledWith(
|
|
||||||
'cd "/home/user/my-project-story-33" && git difftool main...HEAD',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByText("Copied")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses base_branch from the server in the diff command", async () => {
|
|
||||||
const agentList: AgentInfo[] = [
|
|
||||||
{
|
|
||||||
story_id: "33_diff_commands",
|
|
||||||
agent_name: "coder-1",
|
|
||||||
status: "running",
|
|
||||||
session_id: null,
|
|
||||||
worktree_path: "/tmp/wt",
|
|
||||||
base_branch: "develop",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
||||||
|
|
||||||
render(<AgentPanel />);
|
|
||||||
|
|
||||||
const expandButton = await screen.findByText("▶");
|
|
||||||
await userEvent.click(expandButton);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByText('cd "/tmp/wt" && git difftool develop...HEAD'),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to master when base_branch is null", async () => {
|
|
||||||
const agentList: AgentInfo[] = [
|
|
||||||
{
|
|
||||||
story_id: "33_diff_commands",
|
|
||||||
agent_name: "coder-1",
|
|
||||||
status: "running",
|
|
||||||
session_id: null,
|
|
||||||
worktree_path: "/tmp/wt",
|
|
||||||
base_branch: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mockedAgents.listAgents.mockResolvedValue(agentList);
|
|
||||||
|
|
||||||
render(<AgentPanel />);
|
|
||||||
|
|
||||||
const expandButton = await screen.findByText("▶");
|
|
||||||
await userEvent.click(expandButton);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByText('cd "/tmp/wt" && git difftool master...HEAD'),
|
|
||||||
).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({ shouldAdvanceTime: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
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 />);
|
|
||||||
|
|
||||||
// With fake timers active, waitFor's polling setInterval never fires.
|
|
||||||
// Use act to flush pending promises and React state updates instead.
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
container.querySelector(
|
|
||||||
'[data-testid="agent-entry-73_remove_test:coder-1"]',
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Advance timers by 60 seconds and flush React state updates
|
|
||||||
await act(async () => {
|
|
||||||
vi.advanceTimersByTime(60_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Entry should be removed
|
|
||||||
expect(
|
|
||||||
container.querySelector(
|
|
||||||
'[data-testid="agent-entry-73_remove_test:coder-1"]',
|
|
||||||
),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,20 +20,6 @@ interface AgentState {
|
|||||||
terminalAt: number | null;
|
terminalAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<AgentStatusValue, string> = {
|
|
||||||
pending: "#e3b341",
|
|
||||||
running: "#58a6ff",
|
|
||||||
completed: "#7ee787",
|
|
||||||
failed: "#ff7b72",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<AgentStatusValue, string> = {
|
|
||||||
pending: "Pending",
|
|
||||||
running: "Running",
|
|
||||||
completed: "Completed",
|
|
||||||
failed: "Failed",
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTimestamp = (value: Date | null): string => {
|
const formatTimestamp = (value: Date | null): string => {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
return value.toLocaleTimeString([], {
|
return value.toLocaleTimeString([], {
|
||||||
@@ -43,38 +29,6 @@ const formatTimestamp = (value: Date | null): string => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: AgentStatusValue }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "4px",
|
|
||||||
padding: "2px 8px",
|
|
||||||
borderRadius: "999px",
|
|
||||||
fontSize: "0.75em",
|
|
||||||
fontWeight: 600,
|
|
||||||
background: `${STATUS_COLORS[status]}22`,
|
|
||||||
color: STATUS_COLORS[status],
|
|
||||||
border: `1px solid ${STATUS_COLORS[status]}44`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status === "running" && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: "6px",
|
|
||||||
height: "6px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: STATUS_COLORS[status],
|
|
||||||
animation: "pulse 1.5s infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{STATUS_LABELS[status]}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RosterBadge({
|
function RosterBadge({
|
||||||
agent,
|
agent,
|
||||||
activeStoryId,
|
activeStoryId,
|
||||||
@@ -168,157 +122,15 @@ function agentKey(storyId: string, agentName: string): string {
|
|||||||
return `${storyId}:${agentName}`;
|
return `${storyId}:${agentName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffCommand({
|
|
||||||
worktreePath,
|
|
||||||
baseBranch,
|
|
||||||
}: {
|
|
||||||
worktreePath: string;
|
|
||||||
baseBranch: string;
|
|
||||||
}) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const command = `cd "${worktreePath}" && git difftool ${baseBranch}...HEAD`;
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(command);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch {
|
|
||||||
// Fallback: select text for manual copy
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<code
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: "0.7em",
|
|
||||||
color: "#8b949e",
|
|
||||||
background: "#0d1117",
|
|
||||||
padding: "4px 8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #21262d",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{command}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
style={{
|
|
||||||
padding: "3px 8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #30363d",
|
|
||||||
background: copied ? "#238636" : "#21262d",
|
|
||||||
color: copied ? "#fff" : "#8b949e",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "0.7em",
|
|
||||||
fontWeight: 600,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? "Copied" : "Copy"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditorCommand({
|
|
||||||
worktreePath,
|
|
||||||
editorCommand,
|
|
||||||
}: {
|
|
||||||
worktreePath: string;
|
|
||||||
editorCommand: string;
|
|
||||||
}) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const command = `${editorCommand} "${worktreePath}"`;
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(command);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch {
|
|
||||||
// Fallback: select text for manual copy
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<code
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontSize: "0.7em",
|
|
||||||
color: "#8b949e",
|
|
||||||
background: "#0d1117",
|
|
||||||
padding: "4px 8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #21262d",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{command}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
style={{
|
|
||||||
padding: "3px 8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #30363d",
|
|
||||||
background: copied ? "#238636" : "#21262d",
|
|
||||||
color: copied ? "#fff" : "#8b949e",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "0.7em",
|
|
||||||
fontWeight: 600,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? "Copied" : "Open"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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[]>([]);
|
||||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||||
const [editorCommand, setEditorCommand] = useState<string | null>(null);
|
const [editorCommand, setEditorCommand] = useState<string | null>(null);
|
||||||
const [editorInput, setEditorInput] = useState<string>("");
|
const [editorInput, setEditorInput] = useState<string>("");
|
||||||
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>>({});
|
|
||||||
// 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(() => {
|
||||||
@@ -449,79 +261,6 @@ export function AgentPanel() {
|
|||||||
cleanupRefs.current[key] = cleanup;
|
cleanupRefs.current[key] = cleanup;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-scroll log when expanded
|
|
||||||
useEffect(() => {
|
|
||||||
if (expandedKey) {
|
|
||||||
const el = logEndRefs.current[expandedKey];
|
|
||||||
el?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}
|
|
||||||
}, [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);
|
|
||||||
try {
|
|
||||||
await agentsApi.stopAgent(storyId, agentName);
|
|
||||||
cleanupRefs.current[key]?.();
|
|
||||||
delete cleanupRefs.current[key];
|
|
||||||
setAgents((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[key];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
setActionError(`Failed to stop agent for ${storyId}: ${message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEditor = async () => {
|
const handleSaveEditor = async () => {
|
||||||
try {
|
try {
|
||||||
const trimmed = editorInput.trim() || null;
|
const trimmed = editorInput.trim() || null;
|
||||||
@@ -699,175 +438,6 @@ export function AgentPanel() {
|
|||||||
{actionError}
|
{actionError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active agents */}
|
|
||||||
{Object.entries(agents).length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Object.entries(agents).map(([key, a]) => (
|
|
||||||
<div
|
|
||||||
key={`agent-${key}`}
|
|
||||||
data-testid={`agent-entry-${key}`}
|
|
||||||
style={{
|
|
||||||
border: "1px solid #2a2a2a",
|
|
||||||
borderRadius: "8px",
|
|
||||||
background: "#191919",
|
|
||||||
overflow: "hidden",
|
|
||||||
...(a.terminalAt
|
|
||||||
? {
|
|
||||||
animationName: "agentFadeOut",
|
|
||||||
animationDuration: "60s",
|
|
||||||
animationTimingFunction: "linear",
|
|
||||||
animationFillMode: "forwards",
|
|
||||||
animationPlayState:
|
|
||||||
expandedKey === key ? "paused" : "running",
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedKey(expandedKey === key ? null : key)
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
color: "#aaa",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "0.8em",
|
|
||||||
padding: "0 4px",
|
|
||||||
transform:
|
|
||||||
expandedKey === key ? "rotate(90deg)" : "rotate(0deg)",
|
|
||||||
transition: "transform 0.15s",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "0.9em",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: "#888" }}>{a.agentName}</span>
|
|
||||||
<span style={{ color: "#555", margin: "0 6px" }}>
|
|
||||||
{key.split(":")[0]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StatusBadge status={a.status} />
|
|
||||||
|
|
||||||
{(a.status === "running" || a.status === "pending") && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleStop(key.split(":")[0], a.agentName)}
|
|
||||||
style={{
|
|
||||||
padding: "4px 10px",
|
|
||||||
borderRadius: "999px",
|
|
||||||
border: "1px solid #ff7b7244",
|
|
||||||
background: "#ff7b7211",
|
|
||||||
color: "#ff7b72",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "0.75em",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expandedKey === key && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderTop: "1px solid #2a2a2a",
|
|
||||||
padding: "8px 12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{a.worktreePath && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "0.75em",
|
|
||||||
color: "#666",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Worktree: {a.worktreePath}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{a.worktreePath && (
|
|
||||||
<DiffCommand
|
|
||||||
worktreePath={a.worktreePath}
|
|
||||||
baseBranch={a.baseBranch ?? "master"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxHeight: "300px",
|
|
||||||
overflowY: "auto",
|
|
||||||
background: "#111",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "8px",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "0.8em",
|
|
||||||
lineHeight: "1.5",
|
|
||||||
color: "#ccc",
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{a.log.length === 0 ? (
|
|
||||||
<span style={{ color: "#555" }}>
|
|
||||||
{a.status === "pending" || a.status === "running"
|
|
||||||
? "Waiting for output..."
|
|
||||||
: "No output captured."}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
a.log.map((line, i) => (
|
|
||||||
<div
|
|
||||||
key={`log-${key}-${i.toString()}`}
|
|
||||||
style={{
|
|
||||||
color: line.startsWith("[ERROR]")
|
|
||||||
? "#ff7b72"
|
|
||||||
: "#ccc",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{line}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
ref={(el) => {
|
|
||||||
logEndRefs.current[key] = el;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user