Files
stack/apps/web/src/components/terminal/TerminalPanel.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

697 lines
23 KiB
TypeScript

"use client";
/**
* TerminalPanel
*
* Multi-tab terminal panel. Manages multiple PTY sessions via useTerminalSessions,
* rendering one XTerminal per session and keeping all instances mounted (for
* scrollback preservation) while switching visibility with display:none.
*
* Also renders read-only agent output tabs from the orchestrator SSE stream
* via useAgentStream. Agent tabs are automatically added when agents are active
* and can be dismissed when completed or errored.
*
* Features:
* - "+" button to open a new terminal tab
* - Per-tab close button (terminal) / dismiss button (agent)
* - Double-click tab label for inline rename (terminal tabs only)
* - Auto-creates the first terminal session on connect
* - Connection error state
* - Agent tabs: read-only, auto-appear, dismissable
*/
import { useState, useEffect, useRef, useCallback } from "react";
import type { ReactElement, CSSProperties, KeyboardEvent } from "react";
import { XTerminal } from "./XTerminal";
import { AgentTerminal } from "./AgentTerminal";
import { useTerminalSessions } from "@/hooks/useTerminalSessions";
import { useAgentStream } from "@/hooks/useAgentStream";
// ==========================================
// Types
// ==========================================
export interface TerminalPanelProps {
/** Whether the panel is visible */
open: boolean;
/** Called when the user closes the panel */
onClose: () => void;
/** Authentication token for the WebSocket connection */
token?: string;
/** Optional CSS class name */
className?: string;
}
// ==========================================
// Component
// ==========================================
export function TerminalPanel({
open,
onClose,
token = "",
className = "",
}: TerminalPanelProps): ReactElement {
const {
sessions,
activeSessionId,
isConnected,
connectionError,
createSession,
closeSession,
renameSession,
setActiveSession,
sendInput,
resize,
registerOutputCallback,
} = useTerminalSessions({ token });
// ==========================================
// Agent stream
// ==========================================
const { agents, dismissAgent } = useAgentStream();
// ==========================================
// Active tab state (terminal session OR agent)
// "terminal:<sessionId>" or "agent:<agentId>"
// ==========================================
type TabId = string; // prefix-qualified: "terminal:<id>" or "agent:<id>"
const [activeTabId, setActiveTabId] = useState<TabId | null>(null);
// Sync activeTabId with the terminal session activeSessionId when no agent tab is selected
useEffect(() => {
setActiveTabId((prev) => {
// If an agent tab is active, don't clobber it
if (prev?.startsWith("agent:")) return prev;
// Reflect active terminal session
if (activeSessionId !== null) return `terminal:${activeSessionId}`;
return prev;
});
}, [activeSessionId]);
// If the active agent tab is dismissed, fall back to the terminal session
useEffect(() => {
if (activeTabId?.startsWith("agent:")) {
const agentId = activeTabId.slice("agent:".length);
if (!agents.has(agentId)) {
setActiveTabId(activeSessionId !== null ? `terminal:${activeSessionId}` : null);
}
}
}, [agents, activeTabId, activeSessionId]);
// ==========================================
// Inline rename state
// ==========================================
const [editingTabId, setEditingTabId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const editInputRef = useRef<HTMLInputElement>(null);
// Focus the rename input when editing starts
useEffect(() => {
if (editingTabId !== null) {
editInputRef.current?.select();
}
}, [editingTabId]);
// ==========================================
// Auto-create first session on connect
// ==========================================
useEffect(() => {
if (open && isConnected && sessions.size === 0) {
createSession({ name: "Terminal 1" });
}
}, [open, isConnected, sessions.size, createSession]);
// ==========================================
// Tab rename helpers
// ==========================================
const commitRename = useCallback((): void => {
if (editingTabId !== null) {
const trimmed = editingName.trim();
if (trimmed.length > 0) {
renameSession(editingTabId, trimmed);
}
setEditingTabId(null);
setEditingName("");
}
}, [editingTabId, editingName, renameSession]);
const handleTabDoubleClick = useCallback((sessionId: string, currentName: string): void => {
setEditingTabId(sessionId);
setEditingName(currentName);
}, []);
const handleRenameKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") {
commitRename();
} else if (e.key === "Escape") {
setEditingTabId(null);
setEditingName("");
}
},
[commitRename]
);
// ==========================================
// Session control helpers
// ==========================================
const handleCreateTab = useCallback((): void => {
const tabNumber = sessions.size + 1;
createSession({ name: `Terminal ${tabNumber.toString()}` });
}, [sessions.size, createSession]);
const handleCloseTab = useCallback(
(sessionId: string): void => {
closeSession(sessionId);
},
[closeSession]
);
const handleRestart = useCallback(
(sessionId: string, name: string): void => {
closeSession(sessionId);
createSession({ name });
},
[closeSession, createSession]
);
// ==========================================
// Styles
// ==========================================
const panelStyle: CSSProperties = {
background: "var(--bg-deep)",
borderTop: "1px solid var(--border)",
overflow: "hidden",
flexShrink: 0,
display: "flex",
flexDirection: "column",
height: open ? 280 : 0,
transition: "height 0.3s ease",
};
const headerStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "6px 16px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
};
const tabBarStyle: CSSProperties = {
display: "flex",
gap: 2,
alignItems: "center",
flex: 1,
overflow: "hidden",
};
const actionsStyle: CSSProperties = {
display: "flex",
gap: 4,
alignItems: "center",
};
const bodyStyle: CSSProperties = {
flex: 1,
overflow: "hidden",
display: "flex",
flexDirection: "column",
minHeight: 0,
position: "relative",
};
// ==========================================
// Agent status dot color
// ==========================================
const agentDotColor = (status: string): string => {
if (status === "running" || status === "spawning") return "var(--success)";
if (status === "error") return "var(--danger)";
return "var(--muted)";
};
// ==========================================
// Render
// ==========================================
return (
<div
className={className}
style={panelStyle}
role="region"
aria-label="Terminal panel"
aria-hidden={!open}
>
{/* Header */}
<div style={headerStyle}>
{/* Tab bar */}
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
{/* ---- Terminal session tabs ---- */}
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
const tabKey = `terminal:${sessionId}`;
const isActive = tabKey === activeTabId;
const isEditing = sessionId === editingTabId;
const tabStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 6px 3px 10px",
borderRadius: 4,
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: isActive ? "var(--success)" : "var(--muted)",
background: isActive ? "var(--surface)" : "transparent",
border: "none",
outline: "none",
flexShrink: 0,
};
return (
<div key={tabKey} style={tabStyle}>
{isEditing ? (
<input
ref={editInputRef}
value={editingName}
onChange={(e): void => {
setEditingName(e.target.value);
}}
onBlur={commitRename}
onKeyDown={handleRenameKeyDown}
data-testid="tab-rename-input"
style={{
background: "transparent",
border: "none",
outline: "1px solid var(--primary)",
borderRadius: 2,
fontFamily: "var(--mono)",
fontSize: "0.75rem",
color: "var(--text)",
width: `${Math.max(editingName.length, 4).toString()}ch`,
padding: "0 2px",
}}
aria-label="Rename terminal tab"
/>
) : (
<button
role="tab"
aria-selected={isActive}
style={{
background: "transparent",
border: "none",
outline: "none",
fontFamily: "var(--mono)",
fontSize: "0.75rem",
color: isActive ? "var(--success)" : "var(--muted)",
cursor: "pointer",
padding: 0,
}}
onClick={(): void => {
setActiveTabId(tabKey);
setActiveSession(sessionId);
}}
onDoubleClick={(): void => {
handleTabDoubleClick(sessionId, sessionInfo.name);
}}
onMouseEnter={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
}
}}
onMouseLeave={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}
}}
aria-label={sessionInfo.name}
>
{sessionInfo.name}
</button>
)}
{/* Per-tab close button */}
<button
aria-label={`Close ${sessionInfo.name}`}
style={{
width: 16,
height: 16,
borderRadius: 3,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
cursor: "pointer",
background: "transparent",
border: "none",
outline: "none",
padding: 0,
flexShrink: 0,
}}
onClick={(): void => {
handleCloseTab(sessionId);
}}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path
d="M1 1L7 7M7 1L1 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
})}
{/* New tab button */}
<button
aria-label="New terminal tab"
style={{
width: 22,
height: 22,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
cursor: "pointer",
background: "transparent",
border: "none",
outline: "none",
padding: 0,
flexShrink: 0,
}}
onClick={handleCreateTab}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
{/* Plus icon */}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M6 1V11M1 6H11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
{/* ---- Agent section divider (only when agents exist) ---- */}
{agents.size > 0 && (
<div
aria-hidden="true"
style={{
width: 1,
height: 16,
background: "var(--border)",
marginLeft: 6,
marginRight: 4,
flexShrink: 0,
}}
/>
)}
{/* ---- Agent tabs ---- */}
{[...agents.entries()].map(([agentId, agentSession]) => {
const tabKey = `agent:${agentId}`;
const isActive = tabKey === activeTabId;
const canDismiss =
agentSession.status === "completed" || agentSession.status === "error";
const agentTabStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 6px 3px 8px",
borderRadius: 4,
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: isActive ? "var(--text)" : "var(--muted)",
background: isActive ? "var(--surface)" : "transparent",
border: "none",
outline: "none",
flexShrink: 0,
};
return (
<div
key={tabKey}
style={agentTabStyle}
data-testid="agent-tab"
data-agent-id={agentId}
data-agent-status={agentSession.status}
>
{/* Status dot */}
<span
aria-hidden="true"
style={{
display: "inline-block",
width: 6,
height: 6,
borderRadius: "50%",
background: agentDotColor(agentSession.status),
flexShrink: 0,
}}
/>
{/* Agent tab button — read-only, no rename */}
<button
role="tab"
aria-selected={isActive}
style={{
background: "transparent",
border: "none",
outline: "none",
fontFamily: "var(--mono)",
fontSize: "0.75rem",
color: isActive ? "var(--text)" : "var(--muted)",
cursor: "pointer",
padding: 0,
maxWidth: 100,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
onClick={(): void => {
setActiveTabId(tabKey);
}}
onMouseEnter={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
}
}}
onMouseLeave={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}
}}
aria-label={`Agent: ${agentSession.agentType}`}
>
{agentSession.agentType}
</button>
{/* Dismiss button — only for completed/error agents */}
{canDismiss && (
<button
aria-label={`Dismiss ${agentSession.agentType} agent`}
style={{
width: 16,
height: 16,
borderRadius: 3,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
cursor: "pointer",
background: "transparent",
border: "none",
outline: "none",
padding: 0,
flexShrink: 0,
}}
onClick={(): void => {
dismissAgent(agentId);
}}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path
d="M1 1L7 7M7 1L1 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
)}
</div>
);
})}
</div>
{/* Action buttons */}
<div style={actionsStyle}>
{/* Close panel button */}
<button
aria-label="Close terminal"
style={{
width: 22,
height: 22,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
cursor: "pointer",
background: "transparent",
border: "none",
outline: "none",
padding: 0,
}}
onClick={onClose}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
{/* Close icon */}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M1 1L11 11M11 1L1 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
{/* Connection error banner */}
{connectionError !== null && (
<div
role="alert"
style={{
padding: "4px 16px",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--danger)",
backgroundColor: "var(--bg-deep)",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
Connection error: {connectionError}
</div>
)}
{/* Terminal body — keep all XTerminal instances mounted for scrollback */}
<div style={bodyStyle}>
{/* ---- Terminal session panels ---- */}
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
const tabKey = `terminal:${sessionId}`;
const isActive = tabKey === activeTabId;
const termStyle: CSSProperties = {
display: isActive ? "flex" : "none",
flex: 1,
flexDirection: "column",
minHeight: 0,
};
return (
<div key={tabKey} style={termStyle}>
<XTerminal
sessionId={sessionId}
sendInput={sendInput}
resize={resize}
closeSession={closeSession}
registerOutputCallback={registerOutputCallback}
isConnected={isConnected}
sessionStatus={sessionInfo.status}
{...(sessionInfo.exitCode !== undefined ? { exitCode: sessionInfo.exitCode } : {})}
isVisible={isActive && open}
onRestart={(): void => {
handleRestart(sessionId, sessionInfo.name);
}}
style={{ flex: 1, minHeight: 0 }}
/>
</div>
);
})}
{/* ---- Agent session panels ---- */}
{[...agents.entries()].map(([agentId, agentSession]) => {
const tabKey = `agent:${agentId}`;
const isActive = tabKey === activeTabId;
const agentPanelStyle: CSSProperties = {
display: isActive ? "flex" : "none",
flex: 1,
flexDirection: "column",
minHeight: 0,
};
return (
<div key={tabKey} style={agentPanelStyle}>
<AgentTerminal agent={agentSession} style={{ flex: 1, minHeight: 0 }} />
</div>
);
})}
{/* Empty state — show only when no terminal sessions AND no agent sessions */}
{sessions.size === 0 && agents.size === 0 && (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
}}
>
{isConnected ? "Creating terminal..." : (connectionError ?? "Connecting...")}
</div>
)}
</div>
</div>
);
}