All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
697 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|