feat(web): add agent output terminal tabs for orchestrator sessions (CT-ORCH-002)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Implements read-only agent output viewing in the TerminalPanel as a separate tab group alongside interactive PTY terminal sessions. - useAgentStream hook: SSE connection to /api/orchestrator/events with exponential backoff reconnect, per-agent output accumulation, and full lifecycle tracking (spawning→running→completed/error) - AgentTerminal component: read-only <pre>-based output view with ANSI stripping, status indicator (pulse/dot), status badge, elapsed duration, copy-to-clipboard, and error message overlay - TerminalPanel integration: agent tabs appear automatically when agents are active, show colored status dots, dismissable when completed/error, and section divider separates terminal vs agent tabs - 79 new unit tests across useAgentStream, AgentTerminal, and TerminalPanel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,20 @@ vi.mock("./XTerminal", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock AgentTerminal to avoid complexity in panel tests
|
||||
vi.mock("./AgentTerminal", () => ({
|
||||
AgentTerminal: vi.fn(
|
||||
({ agent }: { agent: { agentId: string; agentType: string; status: string } }) => (
|
||||
<div
|
||||
data-testid="mock-agent-terminal"
|
||||
data-agent-id={agent.agentId}
|
||||
data-agent-type={agent.agentType}
|
||||
data-status={agent.status}
|
||||
/>
|
||||
)
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock useTerminalSessions
|
||||
const mockCreateSession = vi.fn();
|
||||
const mockCloseSession = vi.fn();
|
||||
@@ -72,6 +86,29 @@ vi.mock("@/hooks/useTerminalSessions", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock useAgentStream
|
||||
const mockDismissAgent = vi.fn();
|
||||
let mockAgents = new Map<
|
||||
string,
|
||||
{
|
||||
agentId: string;
|
||||
agentType: string;
|
||||
status: "spawning" | "running" | "completed" | "error";
|
||||
outputLines: string[];
|
||||
startedAt: number;
|
||||
}
|
||||
>();
|
||||
let mockAgentStreamConnected = false;
|
||||
|
||||
vi.mock("@/hooks/useAgentStream", () => ({
|
||||
useAgentStream: vi.fn(() => ({
|
||||
agents: mockAgents,
|
||||
isConnected: mockAgentStreamConnected,
|
||||
connectionError: null,
|
||||
dismissAgent: mockDismissAgent,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { TerminalPanel } from "./TerminalPanel";
|
||||
|
||||
// ==========================================
|
||||
@@ -100,6 +137,8 @@ describe("TerminalPanel", () => {
|
||||
mockIsConnected = false;
|
||||
mockConnectionError = null;
|
||||
mockRegisterOutputCallback.mockReturnValue(vi.fn());
|
||||
mockAgents = new Map();
|
||||
mockAgentStreamConnected = false;
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
@@ -425,4 +464,118 @@ describe("TerminalPanel", () => {
|
||||
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Agent tab integration
|
||||
// ==========================================
|
||||
|
||||
describe("agent tab integration", () => {
|
||||
function setOneAgent(status: "spawning" | "running" | "completed" | "error" = "running"): void {
|
||||
mockAgents = new Map([
|
||||
[
|
||||
"agent-1",
|
||||
{
|
||||
agentId: "agent-1",
|
||||
agentType: "worker",
|
||||
status,
|
||||
outputLines: ["Hello from agent\n"],
|
||||
startedAt: Date.now() - 3000,
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
it("renders an agent tab when an agent is active", () => {
|
||||
setOneAgent("running");
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
expect(screen.getAllByTestId("agent-tab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders no agent tabs when agents map is empty", () => {
|
||||
mockAgents = new Map();
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
expect(screen.queryByTestId("agent-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("agent tab button has the agent type as label", () => {
|
||||
setOneAgent("running");
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("agent tab has role=tab", () => {
|
||||
setOneAgent("running");
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows dismiss button for completed agents", () => {
|
||||
setOneAgent("completed");
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows dismiss button for error agents", () => {
|
||||
setOneAgent("error");
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show dismiss button for running agents", () => {
|
||||
setOneAgent("running");
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Dismiss worker agent" })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show dismiss button for spawning agents", () => {
|
||||
setOneAgent("spawning");
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Dismiss worker agent" })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls dismissAgent when dismiss button is clicked", () => {
|
||||
setOneAgent("completed");
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss worker agent" }));
|
||||
expect(mockDismissAgent).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
|
||||
it("renders AgentTerminal when agent tab is active", () => {
|
||||
setOneAgent("running");
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
// Click the agent tab to make it active
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Agent: worker" }));
|
||||
// AgentTerminal should be rendered (mock shows mock-agent-terminal)
|
||||
expect(screen.getByTestId("mock-agent-terminal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a divider between terminal and agent tabs", () => {
|
||||
setTwoSessions();
|
||||
setOneAgent("running");
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
// The divider div is aria-hidden; check it's present in the DOM
|
||||
const tablist = screen.getByRole("tablist");
|
||||
const divider = tablist.querySelector('[aria-hidden="true"][style*="width: 1"]');
|
||||
expect(divider).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("agent tabs show correct data-agent-status", () => {
|
||||
setOneAgent("running");
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
const tab = screen.getByTestId("agent-tab");
|
||||
expect(tab).toHaveAttribute("data-agent-status", "running");
|
||||
});
|
||||
|
||||
it("empty state not shown when agents exist but no terminal sessions", () => {
|
||||
mockSessions = new Map();
|
||||
setOneAgent("running");
|
||||
mockIsConnected = false;
|
||||
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||
expect(screen.queryByText("Connecting...")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user