"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:" or "agent:" // ========================================== type TabId = string; // prefix-qualified: "terminal:" or "agent:" const [activeTabId, setActiveTabId] = useState(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(null); const [editingName, setEditingName] = useState(""); const editInputRef = useRef(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): 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 (
{/* Header */}
{/* Tab bar */}
{/* ---- 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 (
{isEditing ? ( { 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" /> ) : ( )} {/* Per-tab close button */}
); })} {/* New tab button */} {/* ---- Agent section divider (only when agents exist) ---- */} {agents.size > 0 && ( {/* Action buttons */}
{/* Close panel button */}
{/* Connection error banner */} {connectionError !== null && (
Connection error: {connectionError}
)} {/* Terminal body — keep all XTerminal instances mounted for scrollback */}
{/* ---- 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 (
{ handleRestart(sessionId, sessionInfo.name); }} style={{ flex: 1, minHeight: 0 }} />
); })} {/* ---- 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 (
); })} {/* Empty state — show only when no terminal sessions AND no agent sessions */} {sessions.size === 0 && agents.size === 0 && (
{isConnected ? "Creating terminal..." : (connectionError ?? "Connecting...")}
)}
); }