Files
stack/apps/web/src/components/terminal/AgentTerminal.tsx
Jason Woltje 9b2520ce1f
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(web): add agent output terminal tabs for orchestrator sessions (#522)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 04:04:26 +00:00

382 lines
10 KiB
TypeScript

"use client";
/**
* AgentTerminal component
*
* Read-only terminal view for displaying orchestrator agent output.
* Uses a <pre> element with monospace font rather than xterm.js because
* this is read-only agent stdout/stderr, not an interactive PTY.
*
* Features:
* - Displays accumulated output lines with basic ANSI color rendering
* - Status badge (spinning/checkmark/X) indicating agent lifecycle
* - Header bar with agent type, status, and elapsed duration
* - Auto-scrolls to bottom as new output arrives
* - Copy-to-clipboard button for full output
*/
import { useEffect, useRef, useState, useCallback } from "react";
import type { ReactElement, CSSProperties } from "react";
import type { AgentSession, AgentStatus } from "@/hooks/useAgentStream";
// ==========================================
// Types
// ==========================================
export interface AgentTerminalProps {
/** The agent session to display */
agent: AgentSession;
/** Optional CSS class name for the outer container */
className?: string;
/** Optional inline style for the outer container */
style?: CSSProperties;
}
// ==========================================
// ANSI color strip helper
// ==========================================
// Simple ANSI escape sequence stripper — produces readable plain text for <pre>.
// We strip rather than parse for security and simplicity in read-only display.
// eslint-disable-next-line no-control-regex
const ANSI_PATTERN = /\x1b\[[0-9;]*[mGKHF]/g;
function stripAnsi(text: string): string {
return text.replace(ANSI_PATTERN, "");
}
// ==========================================
// Duration helper
// ==========================================
function formatDuration(startedAt: number, endedAt?: number): string {
const elapsed = Math.floor(((endedAt ?? Date.now()) - startedAt) / 1000);
if (elapsed < 60) return `${elapsed.toString()}s`;
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
return `${minutes.toString()}m ${seconds.toString()}s`;
}
// ==========================================
// Status indicator
// ==========================================
interface StatusIndicatorProps {
status: AgentStatus;
}
function StatusIndicator({ status }: StatusIndicatorProps): ReactElement {
const baseStyle: CSSProperties = {
display: "inline-block",
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
};
if (status === "running" || status === "spawning") {
return (
<span
data-testid="status-indicator"
data-status={status}
style={{
...baseStyle,
background: "var(--success)",
animation: "agentPulse 1.5s ease-in-out infinite",
}}
aria-label="Running"
/>
);
}
if (status === "completed") {
return (
<span
data-testid="status-indicator"
data-status={status}
style={{
...baseStyle,
background: "var(--muted)",
}}
aria-label="Completed"
/>
);
}
// error
return (
<span
data-testid="status-indicator"
data-status={status}
style={{
...baseStyle,
background: "var(--danger)",
}}
aria-label="Error"
/>
);
}
// ==========================================
// Status badge
// ==========================================
interface StatusBadgeProps {
status: AgentStatus;
}
function StatusBadge({ status }: StatusBadgeProps): ReactElement {
const colorMap: Record<AgentStatus, string> = {
spawning: "var(--warn)",
running: "var(--success)",
completed: "var(--muted)",
error: "var(--danger)",
};
const labelMap: Record<AgentStatus, string> = {
spawning: "spawning",
running: "running",
completed: "completed",
error: "error",
};
return (
<span
data-testid="status-badge"
style={{
fontSize: "0.65rem",
fontFamily: "var(--mono)",
color: colorMap[status],
border: `1px solid ${colorMap[status]}`,
borderRadius: 3,
padding: "1px 5px",
lineHeight: 1.6,
letterSpacing: "0.03em",
flexShrink: 0,
}}
>
{labelMap[status]}
</span>
);
}
// ==========================================
// Component
// ==========================================
/**
* AgentTerminal renders accumulated agent output in a scrollable pre block.
* It is intentionally read-only — no keyboard input is accepted.
*/
export function AgentTerminal({ agent, className = "", style }: AgentTerminalProps): ReactElement {
const outputRef = useRef<HTMLPreElement>(null);
const [copied, setCopied] = useState(false);
const [tick, setTick] = useState(0);
// ==========================================
// Duration ticker — only runs while active
// ==========================================
useEffect(() => {
if (agent.status === "running" || agent.status === "spawning") {
const id = setInterval(() => {
setTick((t) => t + 1);
}, 1000);
return (): void => {
clearInterval(id);
};
}
return undefined;
}, [agent.status]);
// Consume tick to avoid unused-var lint
void tick;
// ==========================================
// Auto-scroll to bottom on new output
// ==========================================
useEffect(() => {
const el = outputRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [agent.outputLines]);
// ==========================================
// Copy to clipboard
// ==========================================
const handleCopy = useCallback((): void => {
const text = agent.outputLines.map(stripAnsi).join("");
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
});
}, [agent.outputLines]);
// ==========================================
// Styles
// ==========================================
const containerStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
background: "var(--bg-deep)",
overflow: "hidden",
...style,
};
const headerStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
background: "var(--bg-deep)",
};
const titleStyle: CSSProperties = {
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--text)",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const durationStyle: CSSProperties = {
fontSize: "0.65rem",
fontFamily: "var(--mono)",
color: "var(--muted)",
flexShrink: 0,
};
const outputStyle: CSSProperties = {
flex: 1,
overflow: "auto",
margin: 0,
padding: "8px 12px",
fontFamily: "var(--mono)",
fontSize: "0.75rem",
lineHeight: 1.5,
color: "var(--text)",
background: "var(--bg-deep)",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
};
const copyButtonStyle: CSSProperties = {
background: "transparent",
border: "1px solid var(--border)",
borderRadius: 3,
color: copied ? "var(--success)" : "var(--muted)",
cursor: "pointer",
fontSize: "0.65rem",
fontFamily: "var(--mono)",
padding: "2px 6px",
flexShrink: 0,
};
const duration = formatDuration(agent.startedAt, agent.endedAt);
return (
<div
className={className}
style={containerStyle}
role="region"
aria-label={`Agent output: ${agent.agentType}`}
data-testid="agent-terminal"
data-agent-id={agent.agentId}
>
{/* Header */}
<div style={headerStyle} data-testid="agent-terminal-header">
<StatusIndicator status={agent.status} />
<span style={titleStyle} data-testid="agent-type-label">
{agent.agentType}
{agent.jobId !== undefined ? ` · ${agent.jobId}` : ""}
</span>
<StatusBadge status={agent.status} />
<span style={durationStyle} data-testid="agent-duration">
{duration}
</span>
{/* Copy button */}
<button
aria-label="Copy agent output"
style={copyButtonStyle}
onClick={handleCopy}
data-testid="copy-button"
onMouseEnter={(e): void => {
if (!copied) {
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--text-2)";
}
}}
onMouseLeave={(e): void => {
if (!copied) {
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--border)";
}
}}
>
{copied ? "copied" : "copy"}
</button>
</div>
{/* Output area */}
<pre
ref={outputRef}
style={outputStyle}
data-testid="agent-output"
aria-label="Agent output log"
aria-live="polite"
aria-atomic="false"
>
{agent.outputLines.length === 0 ? (
<span style={{ color: "var(--muted)" }}>
{agent.status === "spawning" ? "Spawning agent..." : "Waiting for output..."}
</span>
) : (
agent.outputLines.map(stripAnsi).join("")
)}
</pre>
{/* Error message overlay */}
{agent.status === "error" && agent.errorMessage !== undefined && (
<div
style={{
padding: "4px 12px",
fontSize: "0.7rem",
fontFamily: "var(--mono)",
color: "var(--danger)",
background: "var(--bg-deep)",
borderTop: "1px solid var(--border)",
flexShrink: 0,
}}
data-testid="agent-error-message"
role="alert"
>
Error: {agent.errorMessage}
</div>
)}
{/* Pulse animation keyframes — injected inline via style tag for zero deps */}
<style>{`
@keyframes agentPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
`}</style>
</div>
);
}