From fa9a6a169a042064fe61767c251981b27b85348d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 25 Feb 2026 21:14:23 -0600 Subject: [PATCH] feat(web): implement multi-session terminal tab management (CT-TERM-004) Transforms the single-session terminal into a full multi-tab system: - Add useTerminalSessions hook: manages multiple PTY sessions over a single Socket.IO connection to /terminal namespace. Per-session output multiplexing via callback registry (registerOutputCallback). Sessions tracked in a Map with status (active/exited) and auto-selection of the first available session when active is closed. - Refactor XTerminal: remove internal useTerminal usage; accept sessionId and control functions (sendInput, resize, closeSession, registerOutputCallback) as props from parent. Registers its own output callback on mount and unregisters on unmount. Exit overlay shows restart button using onRestart callback. - Refactor TerminalPanel: fully internally managed tabs via useTerminalSessions. Real tab bar rendered from sessions Map. Features: "+" button for new tabs, per-tab close buttons, double-click inline tab rename (Enter/Escape/blur), connection error banner, auto-create first session on connect, and XTerminal instances kept mounted (display:none) for scrollback preservation when switching tabs. - Add comprehensive test suite: 41 tests for useTerminalSessions, 14 for updated XTerminal, 43 for updated TerminalPanel (76 total). All pass. Co-Authored-By: Claude Opus 4.6 --- .../terminal/TerminalPanel.test.tsx | 399 +++++++--- .../src/components/terminal/TerminalPanel.tsx | 381 ++++++++-- .../components/terminal/XTerminal.test.tsx | 177 +++-- .../web/src/components/terminal/XTerminal.tsx | 183 ++--- apps/web/src/components/terminal/index.ts | 2 +- .../web/src/hooks/useTerminalSessions.test.ts | 690 ++++++++++++++++++ apps/web/src/hooks/useTerminalSessions.ts | 381 ++++++++++ 7 files changed, 1913 insertions(+), 300 deletions(-) create mode 100644 apps/web/src/hooks/useTerminalSessions.test.ts create mode 100644 apps/web/src/hooks/useTerminalSessions.ts diff --git a/apps/web/src/components/terminal/TerminalPanel.test.tsx b/apps/web/src/components/terminal/TerminalPanel.test.tsx index 3ee9d83..7fc5902 100644 --- a/apps/web/src/components/terminal/TerminalPanel.test.tsx +++ b/apps/web/src/components/terminal/TerminalPanel.test.tsx @@ -1,6 +1,6 @@ /** * @file TerminalPanel.test.tsx - * @description Unit tests for the TerminalPanel component + * @description Unit tests for the TerminalPanel component — multi-tab scenarios */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -13,27 +13,93 @@ import type { ReactElement } from "react"; // Mock XTerminal to avoid xterm.js DOM dependencies in panel tests vi.mock("./XTerminal", () => ({ - XTerminal: vi.fn(({ token, isVisible }: { token: string; isVisible: boolean }) => ( -
- )), + XTerminal: vi.fn( + ({ + sessionId, + isVisible, + sessionStatus, + }: { + sessionId: string; + isVisible: boolean; + sessionStatus: string; + }) => ( +
+ ) + ), +})); + +// Mock useTerminalSessions +const mockCreateSession = vi.fn(); +const mockCloseSession = vi.fn(); +const mockRenameSession = vi.fn(); +const mockSetActiveSession = vi.fn(); +const mockSendInput = vi.fn(); +const mockResize = vi.fn(); +const mockRegisterOutputCallback = vi.fn(() => vi.fn()); + +// Mutable state for the mock — tests update these +let mockSessions = new Map< + string, + { + sessionId: string; + name: string; + status: "active" | "exited"; + exitCode?: number; + } +>(); +let mockActiveSessionId: string | null = null; +let mockIsConnected = false; +let mockConnectionError: string | null = null; + +vi.mock("@/hooks/useTerminalSessions", () => ({ + useTerminalSessions: vi.fn(() => ({ + sessions: mockSessions, + activeSessionId: mockActiveSessionId, + isConnected: mockIsConnected, + connectionError: mockConnectionError, + createSession: mockCreateSession, + closeSession: mockCloseSession, + renameSession: mockRenameSession, + setActiveSession: mockSetActiveSession, + sendInput: mockSendInput, + resize: mockResize, + registerOutputCallback: mockRegisterOutputCallback, + })), })); import { TerminalPanel } from "./TerminalPanel"; +// ========================================== +// Helpers +// ========================================== + +function setTwoSessions(): void { + mockSessions = new Map([ + ["session-1", { sessionId: "session-1", name: "Terminal 1", status: "active" }], + ["session-2", { sessionId: "session-2", name: "Terminal 2", status: "active" }], + ]); + mockActiveSessionId = "session-1"; +} + // ========================================== // Tests // ========================================== describe("TerminalPanel", () => { const onClose = vi.fn(); - const onTabChange = vi.fn(); beforeEach(() => { vi.clearAllMocks(); + mockSessions = new Map(); + mockActiveSessionId = null; + mockIsConnected = false; + mockConnectionError = null; + mockRegisterOutputCallback.mockReturnValue(vi.fn()); }); // ========================================== @@ -60,112 +126,249 @@ describe("TerminalPanel", () => { expect(panel).toHaveStyle({ height: "0px" }); }); - it("passes isVisible=true to XTerminal when open", () => { + it("renders empty state when no sessions exist", () => { render(() as ReactElement); - const xterm = screen.getByTestId("mock-xterminal"); - expect(xterm).toHaveAttribute("data-visible", "true"); + // No XTerminal instances should be mounted + expect(screen.queryByTestId("mock-xterminal")).not.toBeInTheDocument(); }); - it("passes isVisible=false to XTerminal when closed", () => { - const { container } = render( - () as ReactElement - ); - // Use container query since the element is inside an aria-hidden region - const xterm = container.querySelector('[data-testid="mock-xterminal"]'); - expect(xterm).toHaveAttribute("data-visible", "false"); + it("shows connecting message in empty state when not connected", () => { + mockIsConnected = false; + render(() as ReactElement); + expect(screen.getByText("Connecting...")).toBeInTheDocument(); }); - it("passes token to XTerminal", () => { - render( - () as ReactElement - ); - const xterm = screen.getByTestId("mock-xterminal"); - expect(xterm).toHaveAttribute("data-token", "my-auth-token"); + it("shows creating message in empty state when connected", () => { + mockIsConnected = true; + render(() as ReactElement); + expect(screen.getByText("Creating terminal...")).toBeInTheDocument(); }); }); // ========================================== - // Tab bar + // Tab bar from sessions // ========================================== describe("tab bar", () => { - it("renders default tabs when none provided", () => { + it("renders a tab for each session", () => { + setTwoSessions(); render(() as ReactElement); - expect(screen.getByRole("tab", { name: "main" })).toBeInTheDocument(); - }); - - it("renders custom tabs", () => { - const tabs = [ - { id: "tab1", label: "Terminal 1" }, - { id: "tab2", label: "Terminal 2" }, - ]; - render( - ( - - ) as ReactElement - ); expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument(); }); - it("marks the active tab as selected", () => { - const tabs = [ - { id: "tab1", label: "Tab 1" }, - { id: "tab2", label: "Tab 2" }, - ]; - render( - ( - - ) as ReactElement + it("marks the active session tab as selected", () => { + setTwoSessions(); + mockActiveSessionId = "session-2"; + render(() as ReactElement); + expect(screen.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute( + "aria-selected", + "true" + ); + expect(screen.getByRole("tab", { name: "Terminal 1" })).toHaveAttribute( + "aria-selected", + "false" ); - const tab2 = screen.getByRole("tab", { name: "Tab 2" }); - expect(tab2).toHaveAttribute("aria-selected", "true"); }); - it("calls onTabChange when a tab is clicked", () => { - const tabs = [ - { id: "tab1", label: "Tab 1" }, - { id: "tab2", label: "Tab 2" }, - ]; - render( - ( - - ) as ReactElement - ); - fireEvent.click(screen.getByRole("tab", { name: "Tab 2" })); - expect(onTabChange).toHaveBeenCalledWith("tab2"); + it("calls setActiveSession when a tab is clicked", () => { + setTwoSessions(); + render(() as ReactElement); + fireEvent.click(screen.getByRole("tab", { name: "Terminal 2" })); + expect(mockSetActiveSession).toHaveBeenCalledWith("session-2"); + }); + + it("has tablist role on the tab bar", () => { + render(() as ReactElement); + expect(screen.getByRole("tablist")).toBeInTheDocument(); }); }); // ========================================== - // Close button + // New tab button // ========================================== - describe("close button", () => { - it("renders the close button", () => { + describe("new tab button", () => { + it("renders the new tab button", () => { + render(() as ReactElement); + expect(screen.getByRole("button", { name: "New terminal tab" })).toBeInTheDocument(); + }); + + it("calls createSession when new tab button is clicked", () => { + setTwoSessions(); + render(() as ReactElement); + fireEvent.click(screen.getByRole("button", { name: "New terminal tab" })); + expect(mockCreateSession).toHaveBeenCalledWith( + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ name: expect.any(String) }) + ); + }); + }); + + // ========================================== + // Per-tab close button + // ========================================== + + describe("per-tab close button", () => { + it("renders a close button for each tab", () => { + setTwoSessions(); + render(() as ReactElement); + expect(screen.getByRole("button", { name: "Close Terminal 1" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Close Terminal 2" })).toBeInTheDocument(); + }); + + it("calls closeSession with the correct sessionId when tab close is clicked", () => { + setTwoSessions(); + render(() as ReactElement); + fireEvent.click(screen.getByRole("button", { name: "Close Terminal 1" })); + expect(mockCloseSession).toHaveBeenCalledWith("session-1"); + }); + }); + + // ========================================== + // Panel close button + // ========================================== + + describe("panel close button", () => { + it("renders the close panel button", () => { render(() as ReactElement); expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument(); }); - it("calls onClose when close button is clicked", () => { + it("calls onClose when close panel button is clicked", () => { render(() as ReactElement); fireEvent.click(screen.getByRole("button", { name: "Close terminal" })); expect(onClose).toHaveBeenCalledTimes(1); }); }); + // ========================================== + // Multi-tab XTerminal rendering + // ========================================== + + describe("multi-tab terminal rendering", () => { + it("renders an XTerminal for each session", () => { + setTwoSessions(); + render(() as ReactElement); + const terminals = screen.getAllByTestId("mock-xterminal"); + expect(terminals).toHaveLength(2); + }); + + it("shows the active session terminal as visible", () => { + setTwoSessions(); + mockActiveSessionId = "session-1"; + render(() as ReactElement); + const terminal1 = screen + .getAllByTestId("mock-xterminal") + .find((el) => el.getAttribute("data-session-id") === "session-1"); + expect(terminal1).toHaveAttribute("data-visible", "true"); + }); + + it("hides inactive session terminals", () => { + setTwoSessions(); + mockActiveSessionId = "session-1"; + render(() as ReactElement); + const terminal2 = screen + .getAllByTestId("mock-xterminal") + .find((el) => el.getAttribute("data-session-id") === "session-2"); + expect(terminal2).toHaveAttribute("data-visible", "false"); + }); + + it("passes sessionStatus to XTerminal", () => { + mockSessions = new Map([ + [ + "session-1", + { sessionId: "session-1", name: "Terminal 1", status: "exited", exitCode: 0 }, + ], + ]); + mockActiveSessionId = "session-1"; + render(() as ReactElement); + const terminal = screen.getByTestId("mock-xterminal"); + expect(terminal).toHaveAttribute("data-status", "exited"); + }); + + it("passes isVisible=false to all terminals when panel is closed", () => { + setTwoSessions(); + const { container } = render( + () as ReactElement + ); + const terminals = container.querySelectorAll('[data-testid="mock-xterminal"]'); + terminals.forEach((terminal) => { + expect(terminal).toHaveAttribute("data-visible", "false"); + }); + }); + }); + + // ========================================== + // Inline tab rename + // ========================================== + + describe("tab rename", () => { + it("shows a rename input when a tab is double-clicked", () => { + setTwoSessions(); + render(() as ReactElement); + fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" })); + expect(screen.getByTestId("tab-rename-input")).toBeInTheDocument(); + }); + + it("calls renameSession when rename input loses focus", () => { + setTwoSessions(); + render(() as ReactElement); + fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" })); + + const input = screen.getByTestId("tab-rename-input"); + fireEvent.change(input, { target: { value: "Custom Shell" } }); + fireEvent.blur(input); + + expect(mockRenameSession).toHaveBeenCalledWith("session-1", "Custom Shell"); + }); + + it("calls renameSession when Enter is pressed in the rename input", () => { + setTwoSessions(); + render(() as ReactElement); + fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" })); + + const input = screen.getByTestId("tab-rename-input"); + fireEvent.change(input, { target: { value: "New Name" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(mockRenameSession).toHaveBeenCalledWith("session-1", "New Name"); + }); + + it("cancels rename when Escape is pressed", () => { + setTwoSessions(); + render(() as ReactElement); + fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" })); + + const input = screen.getByTestId("tab-rename-input"); + fireEvent.change(input, { target: { value: "Abandoned Name" } }); + fireEvent.keyDown(input, { key: "Escape" }); + + expect(mockRenameSession).not.toHaveBeenCalled(); + expect(screen.queryByTestId("tab-rename-input")).not.toBeInTheDocument(); + }); + }); + + // ========================================== + // Connection error banner + // ========================================== + + describe("connection error", () => { + it("shows a connection error banner when connectionError is set", () => { + mockConnectionError = "WebSocket connection failed"; + render(() as ReactElement); + const alert = screen.getByRole("alert"); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent(/WebSocket connection failed/); + }); + + it("does not show the error banner when connectionError is null", () => { + mockConnectionError = null; + render(() as ReactElement); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + }); + // ========================================== // Accessibility // ========================================== @@ -175,8 +378,6 @@ describe("TerminalPanel", () => { const { container } = render( () as ReactElement ); - // When aria-hidden=true, testing-library role queries ignore the element's aria-label. - // Use a direct DOM query to verify the attribute. const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]'); expect(panel).toHaveAttribute("aria-hidden", "true"); }); @@ -186,10 +387,42 @@ describe("TerminalPanel", () => { const panel = screen.getByRole("region", { name: "Terminal panel" }); expect(panel).toHaveAttribute("aria-hidden", "false"); }); + }); - it("has tablist role on the tab bar", () => { + // ========================================== + // Auto-create session + // ========================================== + + describe("auto-create first session", () => { + it("calls createSession when connected and no sessions exist", () => { + mockIsConnected = true; + mockSessions = new Map(); render(() as ReactElement); - expect(screen.getByRole("tablist")).toBeInTheDocument(); + expect(mockCreateSession).toHaveBeenCalledWith( + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ name: expect.any(String) }) + ); + }); + + it("does not call createSession when sessions already exist", () => { + mockIsConnected = true; + setTwoSessions(); + render(() as ReactElement); + expect(mockCreateSession).not.toHaveBeenCalled(); + }); + + it("does not call createSession when not connected", () => { + mockIsConnected = false; + mockSessions = new Map(); + render(() as ReactElement); + expect(mockCreateSession).not.toHaveBeenCalled(); + }); + + it("does not call createSession when panel is closed", () => { + mockIsConnected = true; + mockSessions = new Map(); + render(() as ReactElement); + expect(mockCreateSession).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/web/src/components/terminal/TerminalPanel.tsx b/apps/web/src/components/terminal/TerminalPanel.tsx index 8da853a..1309db7 100644 --- a/apps/web/src/components/terminal/TerminalPanel.tsx +++ b/apps/web/src/components/terminal/TerminalPanel.tsx @@ -3,35 +3,38 @@ /** * TerminalPanel * - * Shell panel that wraps the XTerminal component with a tab bar and close button. - * Replaces the former mock terminal with a real xterm.js PTY terminal. + * 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. + * + * Features: + * - "+" button to open a new tab + * - Per-tab close button + * - Double-click tab label for inline rename + * - Auto-creates the first session on connect + * - Connection error state */ -import type { ReactElement, CSSProperties } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; +import type { ReactElement, CSSProperties, KeyboardEvent } from "react"; import { XTerminal } from "./XTerminal"; +import { useTerminalSessions } from "@/hooks/useTerminalSessions"; // ========================================== -// Types (retained for backwards compatibility) +// Types // ========================================== -export interface TerminalTab { - id: string; - label: string; -} - export interface TerminalPanelProps { + /** Whether the panel is visible */ open: boolean; + /** Called when the user closes the panel */ onClose: () => void; - tabs?: TerminalTab[]; - activeTab?: string; - onTabChange?: (id: string) => void; /** Authentication token for the WebSocket connection */ token?: string; + /** Optional CSS class name */ className?: string; } -const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }]; - // ========================================== // Component // ========================================== @@ -39,14 +42,107 @@ const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }]; export function TerminalPanel({ open, onClose, - tabs, - activeTab, - onTabChange, token = "", className = "", }: TerminalPanelProps): ReactElement { - const resolvedTabs = tabs ?? defaultTabs; - const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? ""; + const { + sessions, + activeSessionId, + isConnected, + connectionError, + createSession, + closeSession, + renameSession, + setActiveSession, + sendInput, + resize, + registerOutputCallback, + } = useTerminalSessions({ token }); + + // ========================================== + // 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)", @@ -71,12 +167,15 @@ export function TerminalPanel({ const tabBarStyle: CSSProperties = { display: "flex", gap: 2, + alignItems: "center", + flex: 1, + overflow: "hidden", }; const actionsStyle: CSSProperties = { - marginLeft: "auto", display: "flex", gap: 4, + alignItems: "center", }; const bodyStyle: CSSProperties = { @@ -85,8 +184,13 @@ export function TerminalPanel({ display: "flex", flexDirection: "column", minHeight: 0, + position: "relative", }; + // ========================================== + // Render + // ========================================== + return (
{/* Tab bar */}
- {resolvedTabs.map((tab) => { - const isActive = tab.id === resolvedActiveTab; + {[...sessions.entries()].map(([sessionId, sessionInfo]) => { + const isActive = sessionId === activeSessionId; + const isEditing = sessionId === editingTabId; + const tabStyle: CSSProperties = { - padding: "3px 10px", + 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)", - cursor: "pointer", background: isActive ? "var(--surface)" : "transparent", border: "none", outline: "none", + flexShrink: 0, }; return ( - + )} + + {/* Per-tab close button */} + + }} + > + + +
); })} + + {/* New tab button */} +
{/* Action buttons */}
+ {/* Close panel button */}
- {/* Terminal body */} + {/* Connection error banner */} + {connectionError !== null && ( +
+ Connection error: {connectionError} +
+ )} + + {/* Terminal body — keep all XTerminal instances mounted for scrollback */}
- + {[...sessions.entries()].map(([sessionId, sessionInfo]) => { + const isActive = sessionId === activeSessionId; + const termStyle: CSSProperties = { + display: isActive ? "flex" : "none", + flex: 1, + flexDirection: "column", + minHeight: 0, + }; + + return ( +
+ { + handleRestart(sessionId, sessionInfo.name); + }} + style={{ flex: 1, minHeight: 0 }} + /> +
+ ); + })} + + {/* Empty state */} + {sessions.size === 0 && ( +
+ {isConnected ? "Creating terminal..." : (connectionError ?? "Connecting...")} +
+ )}
); diff --git a/apps/web/src/components/terminal/XTerminal.test.tsx b/apps/web/src/components/terminal/XTerminal.test.tsx index 201fb8e..5f2da13 100644 --- a/apps/web/src/components/terminal/XTerminal.test.tsx +++ b/apps/web/src/components/terminal/XTerminal.test.tsx @@ -4,21 +4,13 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import type { ReactElement } from "react"; // ========================================== // Mocks — set up before importing components // ========================================== -// Mock socket.io-client -vi.mock("socket.io-client"); - -// Mock lib/config -vi.mock("@/lib/config", () => ({ - API_BASE_URL: "http://localhost:3001", -})); - // Mock xterm packages — they require a DOM canvas not available in jsdom const mockTerminalDispose = vi.fn(); const mockTerminalWrite = vi.fn(); @@ -76,26 +68,6 @@ vi.mock("@xterm/addon-web-links", () => ({ // Mock the CSS import vi.mock("@xterm/xterm/css/xterm.css", () => ({})); -// Mock useTerminal hook -const mockCreateSession = vi.fn(); -const mockSendInput = vi.fn(); -const mockResize = vi.fn(); -const mockCloseSession = vi.fn(); -let mockIsConnected = false; -let mockSessionId: string | null = null; - -vi.mock("@/hooks/useTerminal", () => ({ - useTerminal: vi.fn(() => ({ - isConnected: mockIsConnected, - sessionId: mockSessionId, - createSession: mockCreateSession, - sendInput: mockSendInput, - resize: mockResize, - closeSession: mockCloseSession, - connectionError: null, - })), -})); - // Mock ResizeObserver const mockObserve = vi.fn(); const mockUnobserve = vi.fn(); @@ -128,6 +100,31 @@ vi.stubGlobal( import { XTerminal } from "./XTerminal"; +// ========================================== +// Default props factory +// ========================================== + +const mockSendInput = vi.fn(); +const mockResize = vi.fn(); +const mockCloseSession = vi.fn(); +const mockRegisterOutputCallback = vi.fn(() => vi.fn()); // returns unsubscribe fn +const mockOnRestart = vi.fn(); + +function makeDefaultProps( + overrides: Partial[0]> = {} +): Parameters[0] { + return { + sessionId: "session-test", + sendInput: mockSendInput, + resize: mockResize, + closeSession: mockCloseSession, + registerOutputCallback: mockRegisterOutputCallback, + isConnected: false, + sessionStatus: "active" as const, + ...overrides, + }; +} + // ========================================== // Tests // ========================================== @@ -135,10 +132,9 @@ import { XTerminal } from "./XTerminal"; describe("XTerminal", () => { beforeEach(() => { vi.clearAllMocks(); - mockIsConnected = false; - mockSessionId = null; mockTerminalCols = 80; mockTerminalRows = 24; + mockRegisterOutputCallback.mockReturnValue(vi.fn()); }); afterEach(() => { @@ -151,66 +147,101 @@ describe("XTerminal", () => { describe("rendering", () => { it("renders the terminal container", () => { - render(() as ReactElement); + render(() as ReactElement); expect(screen.getByTestId("xterminal-container")).toBeInTheDocument(); }); it("renders the xterm viewport div", () => { - render(() as ReactElement); + render(() as ReactElement); expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument(); }); it("applies the className prop to the container", () => { - render(() as ReactElement); + render(() as ReactElement); expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class"); }); - it("shows connecting message when not connected", () => { - mockIsConnected = false; + it("sets data-session-id on the container", () => { + render(() as ReactElement); + expect(screen.getByTestId("xterminal-container")).toHaveAttribute( + "data-session-id", + "my-session" + ); + }); - render(() as ReactElement); + it("shows connecting message when not connected and session is active", () => { + render(() as ReactElement); expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument(); }); - it("does not show connecting message when connected", async () => { - mockIsConnected = true; + it("does not show connecting message when connected", () => { + render(() as ReactElement); + expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument(); + }); - const { useTerminal } = await import("@/hooks/useTerminal"); - vi.mocked(useTerminal).mockReturnValue({ - isConnected: true, - sessionId: "session-xyz", - createSession: mockCreateSession, - sendInput: mockSendInput, - resize: mockResize, - closeSession: mockCloseSession, - connectionError: null, - }); - - render(() as ReactElement); + it("does not show connecting message when session has exited", () => { + render( + ( + + ) as ReactElement + ); expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument(); }); }); // ========================================== - // useTerminal integration + // Exit overlay // ========================================== - describe("useTerminal integration", () => { - it("passes the token to useTerminal", async () => { - const { useTerminal } = await import("@/hooks/useTerminal"); - render(() as ReactElement); - expect(vi.mocked(useTerminal)).toHaveBeenCalledWith( - expect.objectContaining({ token: "my-auth-token" }) - ); + describe("exit overlay", () => { + it("shows restart button when session has exited", () => { + render(() as ReactElement); + expect(screen.getByRole("button", { name: /restart terminal/i })).toBeInTheDocument(); }); - it("passes onOutput, onExit, onError callbacks to useTerminal", async () => { - const { useTerminal } = await import("@/hooks/useTerminal"); - render(() as ReactElement); - const callArgs = vi.mocked(useTerminal).mock.calls[0]?.[0]; - expect(typeof callArgs?.onOutput).toBe("function"); - expect(typeof callArgs?.onExit).toBe("function"); - expect(typeof callArgs?.onError).toBe("function"); + it("does not show restart button when session is active", () => { + render(() as ReactElement); + expect(screen.queryByRole("button", { name: /restart terminal/i })).not.toBeInTheDocument(); + }); + + it("shows exit code in restart button when provided", () => { + render( + ( + + ) as ReactElement + ); + expect(screen.getByRole("button", { name: /exit 1/i })).toBeInTheDocument(); + }); + + it("calls onRestart when restart button is clicked", () => { + render( + ( + + ) as ReactElement + ); + fireEvent.click(screen.getByRole("button", { name: /restart terminal/i })); + expect(mockOnRestart).toHaveBeenCalledTimes(1); + }); + }); + + // ========================================== + // Output callback registration + // ========================================== + + describe("registerOutputCallback", () => { + it("registers a callback for its sessionId on mount", () => { + render(() as ReactElement); + expect(mockRegisterOutputCallback).toHaveBeenCalledWith("test-session", expect.any(Function)); + }); + + it("calls the returned unsubscribe function on unmount", () => { + const unsubscribe = vi.fn(); + mockRegisterOutputCallback.mockReturnValue(unsubscribe); + + const { unmount } = render(() as ReactElement); + unmount(); + + expect(unsubscribe).toHaveBeenCalled(); }); }); @@ -220,8 +251,20 @@ describe("XTerminal", () => { describe("accessibility", () => { it("has an accessible region role", () => { - render(() as ReactElement); + render(() as ReactElement); expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument(); }); }); + + // ========================================== + // Visibility + // ========================================== + + describe("isVisible", () => { + it("renders with isVisible=true by default", () => { + render(() as ReactElement); + // Container is present; isVisible affects re-fit timing + expect(screen.getByTestId("xterminal-container")).toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/components/terminal/XTerminal.tsx b/apps/web/src/components/terminal/XTerminal.tsx index b11dd2e..b25a2f6 100644 --- a/apps/web/src/components/terminal/XTerminal.tsx +++ b/apps/web/src/components/terminal/XTerminal.tsx @@ -3,29 +3,55 @@ /** * XTerminal component * - * Renders a real xterm.js terminal connected to the backend /terminal WebSocket namespace. - * Handles resize, copy/paste, theme, and session lifecycle. + * Renders a real xterm.js terminal. The parent (TerminalPanel via useTerminalSessions) + * owns the WebSocket connection and session lifecycle. This component receives the + * sessionId and control functions as props and registers for output data specific + * to its session. + * + * Handles resize, copy/paste, theme, exit overlay, and reconnect. */ -import { useEffect, useRef, useCallback, useState } from "react"; +import { useEffect, useRef, useCallback } from "react"; import type { ReactElement, CSSProperties } from "react"; import "@xterm/xterm/css/xterm.css"; import type { Terminal as XTerm } from "@xterm/xterm"; import type { FitAddon as XFitAddon } from "@xterm/addon-fit"; -import { useTerminal } from "@/hooks/useTerminal"; +import type { SessionStatus } from "@/hooks/useTerminalSessions"; // ========================================== // Types // ========================================== export interface XTerminalProps { - /** Authentication token for the WebSocket connection */ - token: string; + /** Session identifier (provided by useTerminalSessions) */ + sessionId: string; + /** Send keyboard input to this session */ + sendInput: (sessionId: string, data: string) => void; + /** Notify the server of a terminal resize */ + resize: (sessionId: string, cols: number, rows: number) => void; + /** Close this PTY session */ + closeSession: (sessionId: string) => void; + /** + * Register a callback to receive output for this session. + * Returns an unsubscribe function. + */ + registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void; + /** Whether the WebSocket is currently connected */ + isConnected: boolean; + /** Current PTY process status */ + sessionStatus: SessionStatus; + /** Exit code, populated when sessionStatus === 'exited' */ + exitCode?: number; + /** + * Called when the user clicks the restart button after the session has exited. + * The parent is responsible for closing the old session and creating a new one. + */ + onRestart?: () => void; /** Optional CSS class name for the outer container */ className?: string; /** Optional inline styles for the outer container */ style?: CSSProperties; - /** Whether the terminal is visible (used to trigger re-fit) */ + /** Whether the terminal is visible (used to trigger re-fit on tab switch) */ isVisible?: boolean; } @@ -45,8 +71,6 @@ function getCssVar(varName: string, fallback: string): string { /** * Build an xterm.js ITheme object from the current design system CSS variables. - * All 5 themes (dark, light, aurora, midnight, sunlit, ocean) update --ms-* primitives - * which flow through to the semantic aliases we read here. */ function buildXtermTheme(): Record { return { @@ -82,11 +106,20 @@ function buildXtermTheme(): Record { // ========================================== /** - * XTerminal renders a real PTY terminal powered by xterm.js, - * connected to the backend /terminal WebSocket namespace. + * XTerminal renders a real PTY terminal powered by xterm.js. + * The parent provides the sessionId and control functions; this component + * registers for output data and manages the xterm.js instance lifecycle. */ export function XTerminal({ - token, + sessionId, + sendInput, + resize, + closeSession: _closeSession, + registerOutputCallback, + isConnected, + sessionStatus, + exitCode, + onRestart, className = "", style, isVisible = true, @@ -97,41 +130,7 @@ export function XTerminal({ const resizeObserverRef = useRef(null); const isTerminalMountedRef = useRef(false); - const [hasExited, setHasExited] = useState(false); - const [exitCode, setExitCode] = useState(null); - - // ========================================== - // Terminal session callbacks - // ========================================== - - const handleOutput = useCallback((sessionId: string, data: string): void => { - void sessionId; // sessionId is single-session in this component - terminalRef.current?.write(data); - }, []); - - const handleExit = useCallback((event: { sessionId: string; exitCode: number }): void => { - void event.sessionId; - setHasExited(true); - setExitCode(event.exitCode); - const term = terminalRef.current; - if (term) { - term.write(`\r\n\x1b[33m[Process exited with code ${event.exitCode.toString()}]\x1b[0m\r\n`); - } - }, []); - - const handleError = useCallback((message: string): void => { - const term = terminalRef.current; - if (term) { - term.write(`\r\n\x1b[31m[Error: ${message}]\x1b[0m\r\n`); - } - }, []); - - const { isConnected, sessionId, createSession, sendInput, resize, closeSession } = useTerminal({ - token, - onOutput: handleOutput, - onExit: handleExit, - onError: handleError, - }); + const hasExited = sessionStatus === "exited"; // ========================================== // Fit helper @@ -144,11 +143,11 @@ export function XTerminal({ try { fitAddon.fit(); - resize(terminal.cols, terminal.rows); + resize(sessionId, terminal.cols, terminal.rows); } catch { // Ignore fit errors (e.g., when container has zero dimensions) } - }, [resize]); + }, [resize, sessionId]); // ========================================== // Mount xterm.js terminal (client-only) @@ -217,8 +216,20 @@ export function XTerminal({ return (): void => { cancelled = true; }; + // Intentionally empty dep array — mount once only }, []); + // ========================================== + // Register output callback for this session + // ========================================== + + useEffect(() => { + const unregister = registerOutputCallback(sessionId, (data: string) => { + terminalRef.current?.write(data); + }); + return unregister; + }, [sessionId, registerOutputCallback]); + // ========================================== // Re-fit when visibility changes // ========================================== @@ -243,39 +254,13 @@ export function XTerminal({ if (!terminal) return; const disposable = terminal.onData((data: string): void => { - sendInput(data); + sendInput(sessionId, data); }); return (): void => { disposable.dispose(); }; - }, [sendInput]); - - // ========================================== - // Create PTY session when connected - // ========================================== - - useEffect(() => { - if (!isConnected || sessionId !== null) return; - - const terminal = terminalRef.current; - const fitAddon = fitAddonRef.current; - - let cols = 80; - let rows = 24; - - if (terminal && fitAddon) { - try { - fitAddon.fit(); - cols = terminal.cols; - rows = terminal.rows; - } catch { - // Use defaults - } - } - - createSession({ cols, rows }); - }, [isConnected, sessionId, createSession]); + }, [sendInput, sessionId]); // ========================================== // Update xterm theme when data-theme attribute changes @@ -309,16 +294,26 @@ export function XTerminal({ resizeObserverRef.current?.disconnect(); resizeObserverRef.current = null; - // Close PTY session - closeSession(); - // Dispose xterm terminal terminalRef.current?.dispose(); terminalRef.current = null; fitAddonRef.current = null; isTerminalMountedRef.current = false; }; - }, [closeSession]); + }, []); + + // ========================================== + // Restart handler + // ========================================== + + const handleRestart = useCallback((): void => { + const terminal = terminalRef.current; + if (terminal) { + terminal.clear(); + } + // Notify parent to close old session and create a new one + onRestart?.(); + }, [onRestart]); // ========================================== // Render @@ -339,8 +334,9 @@ export function XTerminal({ role="region" aria-label="Terminal" data-testid="xterminal-container" + data-session-id={sessionId} > - {/* Status bar */} + {/* Status bar — show when not connected and not exited */} {!isConnected && !hasExited && (
{ - setHasExited(false); - setExitCode(null); - if (isConnected) { - const terminal = terminalRef.current; - const fitAddon = fitAddonRef.current; - let cols = 80; - let rows = 24; - if (terminal && fitAddon) { - try { - cols = terminal.cols; - rows = terminal.rows; - } catch { - // Use defaults - } - } - terminal?.clear(); - createSession({ cols, rows }); - } - }} + onClick={handleRestart} > - Restart terminal {exitCode !== null ? `(exit ${exitCode.toString()})` : ""} + Restart terminal{exitCode !== undefined ? ` (exit ${exitCode.toString()})` : ""}
)} diff --git a/apps/web/src/components/terminal/index.ts b/apps/web/src/components/terminal/index.ts index 5bb8a0a..9c0adeb 100644 --- a/apps/web/src/components/terminal/index.ts +++ b/apps/web/src/components/terminal/index.ts @@ -1,4 +1,4 @@ -export type { TerminalTab, TerminalPanelProps } from "./TerminalPanel"; +export type { TerminalPanelProps } from "./TerminalPanel"; export { TerminalPanel } from "./TerminalPanel"; export type { XTerminalProps } from "./XTerminal"; export { XTerminal } from "./XTerminal"; diff --git a/apps/web/src/hooks/useTerminalSessions.test.ts b/apps/web/src/hooks/useTerminalSessions.test.ts new file mode 100644 index 0000000..db858f3 --- /dev/null +++ b/apps/web/src/hooks/useTerminalSessions.test.ts @@ -0,0 +1,690 @@ +/** + * @file useTerminalSessions.test.ts + * @description Unit tests for the useTerminalSessions hook + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useTerminalSessions } from "./useTerminalSessions"; +import type { Socket } from "socket.io-client"; + +// ========================================== +// Mock socket.io-client +// ========================================== + +vi.mock("socket.io-client"); + +// ========================================== +// Mock lib/config +// ========================================== + +vi.mock("@/lib/config", () => ({ + API_BASE_URL: "http://localhost:3001", +})); + +// ========================================== +// Helpers +// ========================================== + +interface MockSocket { + on: ReturnType; + off: ReturnType; + emit: ReturnType; + disconnect: ReturnType; + connected: boolean; +} + +describe("useTerminalSessions", () => { + let mockSocket: MockSocket; + let socketEventHandlers: Record void>; + let mockIo: ReturnType; + + beforeEach(async () => { + socketEventHandlers = {}; + + mockSocket = { + on: vi.fn((event: string, handler: (data: unknown) => void) => { + socketEventHandlers[event] = handler; + return mockSocket; + }), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + connected: true, + }; + + const socketIo = await import("socket.io-client"); + mockIo = vi.mocked(socketIo.io); + mockIo.mockReturnValue(mockSocket as unknown as Socket); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ========================================== + // Connection lifecycle + // ========================================== + + describe("connection lifecycle", () => { + it("should connect to the /terminal namespace with auth token", () => { + renderHook(() => useTerminalSessions({ token: "test-token" })); + + expect(mockIo).toHaveBeenCalledWith( + expect.stringContaining("/terminal"), + expect.objectContaining({ + auth: { token: "test-token" }, + }) + ); + }); + + it("should start disconnected", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + expect(result.current.isConnected).toBe(false); + }); + + it("should update isConnected when connect event fires", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers.connect?.(undefined); + }); + + await waitFor(() => { + expect(result.current.isConnected).toBe(true); + }); + }); + + it("should set connectionError when connect_error fires", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers.connect_error?.(new Error("Connection refused")); + }); + + await waitFor(() => { + expect(result.current.connectionError).toBe("Connection refused"); + expect(result.current.isConnected).toBe(false); + }); + }); + + it("should not connect when token is empty", () => { + renderHook(() => useTerminalSessions({ token: "" })); + + expect(mockIo).not.toHaveBeenCalled(); + }); + + it("should disconnect socket on unmount", () => { + const { unmount } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + unmount(); + + expect(mockSocket.disconnect).toHaveBeenCalled(); + }); + }); + + // ========================================== + // Session creation + // ========================================== + + describe("createSession", () => { + it("should emit terminal:create when connected", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers.connect?.(undefined); + }); + + act(() => { + result.current.createSession({ name: "bash", cols: 120, rows: 40 }); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:create", { + name: "bash", + cols: 120, + rows: 40, + }); + }); + + it("should not emit terminal:create when disconnected", () => { + mockSocket.connected = false; + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + result.current.createSession(); + }); + + expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:create", expect.anything()); + }); + + it("should add session to sessions map when terminal:created fires", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.has("session-1")).toBe(true); + expect(result.current.sessions.get("session-1")?.name).toBe("Terminal 1"); + expect(result.current.sessions.get("session-1")?.status).toBe("active"); + }); + }); + + it("should set first created session as active", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-1"); + }); + }); + + it("should not change active session when a second session is created", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-1"); + }); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.size).toBe(2); + // Active session should remain session-1 + expect(result.current.activeSessionId).toBe("session-1"); + }); + }); + + it("should manage multiple sessions in the sessions map", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.size).toBe(2); + expect(result.current.sessions.has("session-1")).toBe(true); + expect(result.current.sessions.has("session-2")).toBe(true); + }); + }); + }); + + // ========================================== + // Session close + // ========================================== + + describe("closeSession", () => { + it("should emit terminal:close and remove session from map", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.has("session-1")).toBe(true); + }); + + act(() => { + result.current.closeSession("session-1"); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", { + sessionId: "session-1", + }); + + await waitFor(() => { + expect(result.current.sessions.has("session-1")).toBe(false); + }); + }); + + it("should switch active session to another when active is closed", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-1"); + }); + + act(() => { + result.current.closeSession("session-1"); + }); + + await waitFor(() => { + // Should switch to session-2 + expect(result.current.activeSessionId).toBe("session-2"); + expect(result.current.sessions.has("session-1")).toBe(false); + }); + }); + + it("should set activeSessionId to null when last session is closed", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-1"); + }); + + act(() => { + result.current.closeSession("session-1"); + }); + + await waitFor(() => { + expect(result.current.sessions.size).toBe(0); + expect(result.current.activeSessionId).toBeNull(); + }); + }); + }); + + // ========================================== + // Rename session + // ========================================== + + describe("renameSession", () => { + it("should update the session name in the sessions map", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.get("session-1")?.name).toBe("Terminal 1"); + }); + + act(() => { + result.current.renameSession("session-1", "My Custom Shell"); + }); + + await waitFor(() => { + expect(result.current.sessions.get("session-1")?.name).toBe("My Custom Shell"); + }); + }); + + it("should not affect other session names", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + act(() => { + result.current.renameSession("session-1", "Custom"); + }); + + await waitFor(() => { + expect(result.current.sessions.get("session-1")?.name).toBe("Custom"); + expect(result.current.sessions.get("session-2")?.name).toBe("Terminal 2"); + }); + }); + }); + + // ========================================== + // setActiveSession + // ========================================== + + describe("setActiveSession", () => { + it("should update activeSessionId", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-1"); + }); + + act(() => { + result.current.setActiveSession("session-2"); + }); + + await waitFor(() => { + expect(result.current.activeSessionId).toBe("session-2"); + }); + }); + }); + + // ========================================== + // sendInput + // ========================================== + + describe("sendInput", () => { + it("should emit terminal:input with sessionId and data", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers.connect?.(undefined); + }); + + act(() => { + result.current.sendInput("session-1", "ls -la\n"); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:input", { + sessionId: "session-1", + data: "ls -la\n", + }); + }); + + it("should not emit when disconnected", () => { + mockSocket.connected = false; + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + result.current.sendInput("session-1", "ls\n"); + }); + + expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:input", expect.anything()); + }); + }); + + // ========================================== + // resize + // ========================================== + + describe("resize", () => { + it("should emit terminal:resize with sessionId, cols, and rows", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers.connect?.(undefined); + }); + + act(() => { + result.current.resize("session-1", 120, 40); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:resize", { + sessionId: "session-1", + cols: 120, + rows: 40, + }); + }); + }); + + // ========================================== + // Output callback routing + // ========================================== + + describe("registerOutputCallback", () => { + it("should call the registered callback when terminal:output fires for that session", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + const cb = vi.fn(); + + act(() => { + result.current.registerOutputCallback("session-1", cb); + }); + + act(() => { + socketEventHandlers["terminal:output"]?.({ + sessionId: "session-1", + data: "hello world\r\n", + }); + }); + + expect(cb).toHaveBeenCalledWith("hello world\r\n"); + }); + + it("should not call callback for a different session", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + const cbSession1 = vi.fn(); + const cbSession2 = vi.fn(); + + act(() => { + result.current.registerOutputCallback("session-1", cbSession1); + result.current.registerOutputCallback("session-2", cbSession2); + }); + + act(() => { + socketEventHandlers["terminal:output"]?.({ + sessionId: "session-1", + data: "output for session 1", + }); + }); + + expect(cbSession1).toHaveBeenCalledWith("output for session 1"); + expect(cbSession2).not.toHaveBeenCalled(); + }); + + it("should stop calling callback after unsubscribing", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + const cb = vi.fn(); + let unsubscribe: (() => void) | undefined; + + act(() => { + unsubscribe = result.current.registerOutputCallback("session-1", cb); + }); + + act(() => { + unsubscribe?.(); + }); + + act(() => { + socketEventHandlers["terminal:output"]?.({ + sessionId: "session-1", + data: "should not arrive", + }); + }); + + expect(cb).not.toHaveBeenCalled(); + }); + + it("should support multiple callbacks for the same session", () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + + act(() => { + result.current.registerOutputCallback("session-1", cb1); + result.current.registerOutputCallback("session-1", cb2); + }); + + act(() => { + socketEventHandlers["terminal:output"]?.({ + sessionId: "session-1", + data: "broadcast", + }); + }); + + expect(cb1).toHaveBeenCalledWith("broadcast"); + expect(cb2).toHaveBeenCalledWith("broadcast"); + }); + }); + + // ========================================== + // Exit event + // ========================================== + + describe("terminal:exit handling", () => { + it("should mark session as exited when terminal:exit fires", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.get("session-1")?.status).toBe("active"); + }); + + act(() => { + socketEventHandlers["terminal:exit"]?.({ + sessionId: "session-1", + exitCode: 0, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.get("session-1")?.status).toBe("exited"); + expect(result.current.sessions.get("session-1")?.exitCode).toBe(0); + }); + }); + + it("should not remove the session from the map on exit", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + }); + + act(() => { + socketEventHandlers["terminal:exit"]?.({ + sessionId: "session-1", + exitCode: 1, + }); + }); + + await waitFor(() => { + // Session remains in map — user can restart or close it manually + expect(result.current.sessions.has("session-1")).toBe(true); + }); + }); + }); + + // ========================================== + // Disconnect handling + // ========================================== + + describe("disconnect handling", () => { + it("should mark all active sessions as exited on disconnect", async () => { + const { result } = renderHook(() => useTerminalSessions({ token: "test-token" })); + + act(() => { + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-1", + name: "Terminal 1", + cols: 80, + rows: 24, + }); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-2", + name: "Terminal 2", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessions.size).toBe(2); + }); + + act(() => { + socketEventHandlers.disconnect?.(undefined); + }); + + await waitFor(() => { + expect(result.current.isConnected).toBe(false); + expect(result.current.sessions.get("session-1")?.status).toBe("exited"); + expect(result.current.sessions.get("session-2")?.status).toBe("exited"); + }); + }); + }); +}); diff --git a/apps/web/src/hooks/useTerminalSessions.ts b/apps/web/src/hooks/useTerminalSessions.ts new file mode 100644 index 0000000..0bf8ab5 --- /dev/null +++ b/apps/web/src/hooks/useTerminalSessions.ts @@ -0,0 +1,381 @@ +/** + * useTerminalSessions hook + * + * Manages multiple PTY terminal sessions over a single WebSocket connection + * to the /terminal namespace. Supports creating, closing, renaming, and switching + * between sessions, with per-session output callback multiplexing. + * + * Protocol (from terminal.gateway.ts): + * 1. Connect with auth token in handshake + * 2. Emit terminal:create { name?, cols?, rows? } → receive terminal:created { sessionId, name, cols, rows } + * 3. Emit terminal:input { sessionId, data } to send keystrokes + * 4. Receive terminal:output { sessionId, data } for stdout/stderr + * 5. Emit terminal:resize { sessionId, cols, rows } on window resize + * 6. Emit terminal:close { sessionId } to terminate the PTY + * 7. Receive terminal:exit { sessionId, exitCode, signal } on PTY exit + * 8. Receive terminal:error { message } on errors + */ + +import { useEffect, useRef, useState, useCallback } from "react"; +import type { Socket } from "socket.io-client"; +import { io } from "socket.io-client"; +import { API_BASE_URL } from "@/lib/config"; + +// ========================================== +// Types +// ========================================== + +export type SessionStatus = "active" | "exited"; + +export interface SessionInfo { + /** Session identifier returned by the server */ + sessionId: string; + /** Human-readable tab label */ + name: string; + /** Whether the PTY process is still running */ + status: SessionStatus; + /** Exit code, populated when status === 'exited' */ + exitCode?: number; +} + +export interface CreateSessionOptions { + /** Optional label for the new session */ + name?: string; + /** Terminal columns */ + cols?: number; + /** Terminal rows */ + rows?: number; + /** Working directory */ + cwd?: string; +} + +export interface UseTerminalSessionsOptions { + /** Authentication token for WebSocket handshake */ + token: string; +} + +export interface UseTerminalSessionsReturn { + /** Map of sessionId → SessionInfo */ + sessions: Map; + /** Currently active (visible) session id, or null if none */ + activeSessionId: string | null; + /** Whether the WebSocket is connected */ + isConnected: boolean; + /** Connection error message, if any */ + connectionError: string | null; + /** Create a new PTY session */ + createSession: (options?: CreateSessionOptions) => void; + /** Close an existing PTY session */ + closeSession: (sessionId: string) => void; + /** Rename a session (local label only, not persisted to server) */ + renameSession: (sessionId: string, name: string) => void; + /** Switch the visible session */ + setActiveSession: (sessionId: string) => void; + /** Send keyboard input to a session */ + sendInput: (sessionId: string, data: string) => void; + /** Notify the server of a terminal resize */ + resize: (sessionId: string, cols: number, rows: number) => void; + /** + * Register a callback that receives output data for a specific session. + * Returns an unsubscribe function — call it during cleanup. + */ + registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void; +} + +// ========================================== +// Payload shapes matching terminal.dto.ts +// ========================================== + +interface TerminalCreatedPayload { + sessionId: string; + name: string; + cols: number; + rows: number; +} + +interface TerminalOutputPayload { + sessionId: string; + data: string; +} + +interface TerminalExitPayload { + sessionId: string; + exitCode: number; + signal?: string; +} + +interface TerminalErrorPayload { + message: string; +} + +// ========================================== +// Security validation +// ========================================== + +function validateWebSocketSecurity(url: string): void { + const isProduction = process.env.NODE_ENV === "production"; + const isSecure = url.startsWith("https://") || url.startsWith("wss://"); + + if (isProduction && !isSecure) { + console.warn( + "[Security Warning] Terminal WebSocket using insecure protocol (ws://). " + + "Authentication tokens may be exposed. Use wss:// in production." + ); + } +} + +// ========================================== +// Hook +// ========================================== + +/** + * Hook for managing multiple PTY terminal sessions over a single WebSocket connection. + * + * @param options - Configuration including auth token + * @returns Multi-session terminal state and control functions + */ +export function useTerminalSessions( + options: UseTerminalSessionsOptions +): UseTerminalSessionsReturn { + const { token } = options; + + const socketRef = useRef(null); + // Per-session output callback registry; keyed by sessionId + const outputCallbacksRef = useRef void>>>(new Map()); + + const [sessions, setSessions] = useState>(new Map()); + const [activeSessionId, setActiveSessionIdState] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [connectionError, setConnectionError] = useState(null); + + // ========================================== + // Auto-select first available session when active becomes null + // ========================================== + + useEffect(() => { + if (activeSessionId === null && sessions.size > 0) { + const firstId = sessions.keys().next().value; + if (firstId !== undefined) { + setActiveSessionIdState(firstId); + } + } + }, [activeSessionId, sessions]); + + // ========================================== + // WebSocket connection + // ========================================== + + useEffect(() => { + if (!token) { + return; + } + + const wsUrl = API_BASE_URL; + validateWebSocketSecurity(wsUrl); + + setConnectionError(null); + + const socket = io(`${wsUrl}/terminal`, { + auth: { token }, + transports: ["websocket", "polling"], + }); + + socketRef.current = socket; + + const handleConnect = (): void => { + setIsConnected(true); + setConnectionError(null); + }; + + const handleDisconnect = (): void => { + setIsConnected(false); + // Sessions remain in the Map but are no longer interactive + setSessions((prev) => { + const next = new Map(prev); + for (const [id, info] of next) { + if (info.status === "active") { + next.set(id, { ...info, status: "exited" }); + } + } + return next; + }); + }; + + const handleConnectError = (error: Error): void => { + setConnectionError(error.message || "Terminal connection failed"); + setIsConnected(false); + }; + + const handleTerminalCreated = (payload: TerminalCreatedPayload): void => { + setSessions((prev) => { + const next = new Map(prev); + next.set(payload.sessionId, { + sessionId: payload.sessionId, + name: payload.name, + status: "active", + }); + return next; + }); + // Set as active session if none is currently active + setActiveSessionIdState((prev) => prev ?? payload.sessionId); + }; + + const handleTerminalOutput = (payload: TerminalOutputPayload): void => { + const callbacks = outputCallbacksRef.current.get(payload.sessionId); + if (callbacks) { + for (const cb of callbacks) { + cb(payload.data); + } + } + }; + + const handleTerminalExit = (payload: TerminalExitPayload): void => { + setSessions((prev) => { + const next = new Map(prev); + const session = next.get(payload.sessionId); + if (session) { + next.set(payload.sessionId, { + ...session, + status: "exited", + exitCode: payload.exitCode, + }); + } + return next; + }); + }; + + const handleTerminalError = (payload: TerminalErrorPayload): void => { + console.error("[Terminal] Error:", payload.message); + }; + + socket.on("connect", handleConnect); + socket.on("disconnect", handleDisconnect); + socket.on("connect_error", handleConnectError); + socket.on("terminal:created", handleTerminalCreated); + socket.on("terminal:output", handleTerminalOutput); + socket.on("terminal:exit", handleTerminalExit); + socket.on("terminal:error", handleTerminalError); + + return (): void => { + socket.off("connect", handleConnect); + socket.off("disconnect", handleDisconnect); + socket.off("connect_error", handleConnectError); + socket.off("terminal:created", handleTerminalCreated); + socket.off("terminal:output", handleTerminalOutput); + socket.off("terminal:exit", handleTerminalExit); + socket.off("terminal:error", handleTerminalError); + + // Close all active sessions before disconnecting + const currentSessions = sessions; + for (const [id, info] of currentSessions) { + if (info.status === "active") { + socket.emit("terminal:close", { sessionId: id }); + } + } + + socket.disconnect(); + socketRef.current = null; + }; + // Intentional: token is the only dep that should trigger reconnection + }, [token]); + + // ========================================== + // Control functions + // ========================================== + + const createSession = useCallback((createOptions: CreateSessionOptions = {}): void => { + const socket = socketRef.current; + if (!socket?.connected) { + return; + } + + const payload: Record = {}; + if (createOptions.name !== undefined) payload.name = createOptions.name; + if (createOptions.cols !== undefined) payload.cols = createOptions.cols; + if (createOptions.rows !== undefined) payload.rows = createOptions.rows; + if (createOptions.cwd !== undefined) payload.cwd = createOptions.cwd; + + socket.emit("terminal:create", payload); + }, []); + + const closeSession = useCallback((sessionId: string): void => { + const socket = socketRef.current; + if (socket?.connected) { + socket.emit("terminal:close", { sessionId }); + } + + setSessions((prev) => { + const next = new Map(prev); + next.delete(sessionId); + return next; + }); + + // If closing the active session, activeSessionId becomes null + // and the auto-select useEffect will pick the first remaining session + setActiveSessionIdState((prev) => (prev === sessionId ? null : prev)); + }, []); + + const renameSession = useCallback((sessionId: string, name: string): void => { + setSessions((prev) => { + const next = new Map(prev); + const session = next.get(sessionId); + if (session) { + next.set(sessionId, { ...session, name }); + } + return next; + }); + }, []); + + const setActiveSession = useCallback((sessionId: string): void => { + setActiveSessionIdState(sessionId); + }, []); + + const sendInput = useCallback((sessionId: string, data: string): void => { + const socket = socketRef.current; + if (!socket?.connected) { + return; + } + socket.emit("terminal:input", { sessionId, data }); + }, []); + + const resize = useCallback((sessionId: string, cols: number, rows: number): void => { + const socket = socketRef.current; + if (!socket?.connected) { + return; + } + socket.emit("terminal:resize", { sessionId, cols, rows }); + }, []); + + const registerOutputCallback = useCallback( + (sessionId: string, cb: (data: string) => void): (() => void) => { + const registry = outputCallbacksRef.current; + if (!registry.has(sessionId)) { + registry.set(sessionId, new Set()); + } + // Safe: we just ensured the key exists + const callbackSet = registry.get(sessionId); + if (callbackSet) { + callbackSet.add(cb); + } + + return (): void => { + registry.get(sessionId)?.delete(cb); + }; + }, + [] + ); + + return { + sessions, + activeSessionId, + isConnected, + connectionError, + createSession, + closeSession, + renameSession, + setActiveSession, + sendInput, + resize, + registerOutputCallback, + }; +}