feat(web): implement multi-session terminal tab management (CT-TERM-004)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 21:14:23 -06:00
parent 13aa52aa53
commit fa9a6a169a
7 changed files with 1913 additions and 300 deletions

View File

@@ -1,6 +1,6 @@
/** /**
* @file TerminalPanel.test.tsx * @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"; 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 // Mock XTerminal to avoid xterm.js DOM dependencies in panel tests
vi.mock("./XTerminal", () => ({ vi.mock("./XTerminal", () => ({
XTerminal: vi.fn(({ token, isVisible }: { token: string; isVisible: boolean }) => ( XTerminal: vi.fn(
<div ({
data-testid="mock-xterminal" sessionId,
data-token={token} isVisible,
data-visible={isVisible ? "true" : "false"} sessionStatus,
/> }: {
)), sessionId: string;
isVisible: boolean;
sessionStatus: string;
}) => (
<div
data-testid="mock-xterminal"
data-session-id={sessionId}
data-visible={isVisible ? "true" : "false"}
data-status={sessionStatus}
/>
)
),
}));
// 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"; 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 // Tests
// ========================================== // ==========================================
describe("TerminalPanel", () => { describe("TerminalPanel", () => {
const onClose = vi.fn(); const onClose = vi.fn();
const onTabChange = vi.fn();
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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" }); expect(panel).toHaveStyle({ height: "0px" });
}); });
it("passes isVisible=true to XTerminal when open", () => { it("renders empty state when no sessions exist", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement); render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
const xterm = screen.getByTestId("mock-xterminal"); // No XTerminal instances should be mounted
expect(xterm).toHaveAttribute("data-visible", "true"); expect(screen.queryByTestId("mock-xterminal")).not.toBeInTheDocument();
}); });
it("passes isVisible=false to XTerminal when closed", () => { it("shows connecting message in empty state when not connected", () => {
const { container } = render( mockIsConnected = false;
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
); expect(screen.getByText("Connecting...")).toBeInTheDocument();
// 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("passes token to XTerminal", () => { it("shows creating message in empty state when connected", () => {
render( mockIsConnected = true;
(<TerminalPanel open={true} onClose={onClose} token="my-auth-token" />) as ReactElement render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
); expect(screen.getByText("Creating terminal...")).toBeInTheDocument();
const xterm = screen.getByTestId("mock-xterminal");
expect(xterm).toHaveAttribute("data-token", "my-auth-token");
}); });
}); });
// ========================================== // ==========================================
// Tab bar // Tab bar from sessions
// ========================================== // ==========================================
describe("tab bar", () => { describe("tab bar", () => {
it("renders default tabs when none provided", () => { it("renders a tab for each session", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement); render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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(
(
<TerminalPanel open={true} onClose={onClose} tabs={tabs} token="test-token" />
) as ReactElement
);
expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument();
}); });
it("marks the active tab as selected", () => { it("marks the active session tab as selected", () => {
const tabs = [ setTwoSessions();
{ id: "tab1", label: "Tab 1" }, mockActiveSessionId = "session-2";
{ id: "tab2", label: "Tab 2" }, render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
]; expect(screen.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute(
render( "aria-selected",
( "true"
<TerminalPanel );
open={true} expect(screen.getByRole("tab", { name: "Terminal 1" })).toHaveAttribute(
onClose={onClose} "aria-selected",
tabs={tabs} "false"
activeTab="tab2"
token="test-token"
/>
) as ReactElement
); );
const tab2 = screen.getByRole("tab", { name: "Tab 2" });
expect(tab2).toHaveAttribute("aria-selected", "true");
}); });
it("calls onTabChange when a tab is clicked", () => { it("calls setActiveSession when a tab is clicked", () => {
const tabs = [ setTwoSessions();
{ id: "tab1", label: "Tab 1" }, render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
{ id: "tab2", label: "Tab 2" }, fireEvent.click(screen.getByRole("tab", { name: "Terminal 2" }));
]; expect(mockSetActiveSession).toHaveBeenCalledWith("session-2");
render( });
(
<TerminalPanel it("has tablist role on the tab bar", () => {
open={true} render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
onClose={onClose} expect(screen.getByRole("tablist")).toBeInTheDocument();
tabs={tabs}
onTabChange={onTabChange}
token="test-token"
/>
) as ReactElement
);
fireEvent.click(screen.getByRole("tab", { name: "Tab 2" }));
expect(onTabChange).toHaveBeenCalledWith("tab2");
}); });
}); });
// ========================================== // ==========================================
// Close button // New tab button
// ========================================== // ==========================================
describe("close button", () => { describe("new tab button", () => {
it("renders the close button", () => { it("renders the new tab button", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "New terminal tab" })).toBeInTheDocument();
});
it("calls createSession when new tab button is clicked", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement); render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument(); 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement); render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
fireEvent.click(screen.getByRole("button", { name: "Close terminal" })); fireEvent.click(screen.getByRole("button", { name: "Close terminal" }));
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
}); });
}); });
// ==========================================
// Multi-tab XTerminal rendering
// ==========================================
describe("multi-tab terminal rendering", () => {
it("renders an XTerminal for each session", () => {
setTwoSessions();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
// ========================================== // ==========================================
// Accessibility // Accessibility
// ========================================== // ==========================================
@@ -175,8 +378,6 @@ describe("TerminalPanel", () => {
const { container } = render( const { container } = render(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement (<TerminalPanel open={false} onClose={onClose} token="test-token" />) 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"]'); const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
expect(panel).toHaveAttribute("aria-hidden", "true"); expect(panel).toHaveAttribute("aria-hidden", "true");
}); });
@@ -186,10 +387,42 @@ describe("TerminalPanel", () => {
const panel = screen.getByRole("region", { name: "Terminal panel" }); const panel = screen.getByRole("region", { name: "Terminal panel" });
expect(panel).toHaveAttribute("aria-hidden", "false"); 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement); render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(mockCreateSession).not.toHaveBeenCalled();
});
it("does not call createSession when not connected", () => {
mockIsConnected = false;
mockSessions = new Map();
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(mockCreateSession).not.toHaveBeenCalled();
});
it("does not call createSession when panel is closed", () => {
mockIsConnected = true;
mockSessions = new Map();
render((<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement);
expect(mockCreateSession).not.toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -3,35 +3,38 @@
/** /**
* TerminalPanel * TerminalPanel
* *
* Shell panel that wraps the XTerminal component with a tab bar and close button. * Multi-tab terminal panel. Manages multiple PTY sessions via useTerminalSessions,
* Replaces the former mock terminal with a real xterm.js PTY terminal. * 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 { XTerminal } from "./XTerminal";
import { useTerminalSessions } from "@/hooks/useTerminalSessions";
// ========================================== // ==========================================
// Types (retained for backwards compatibility) // Types
// ========================================== // ==========================================
export interface TerminalTab {
id: string;
label: string;
}
export interface TerminalPanelProps { export interface TerminalPanelProps {
/** Whether the panel is visible */
open: boolean; open: boolean;
/** Called when the user closes the panel */
onClose: () => void; onClose: () => void;
tabs?: TerminalTab[];
activeTab?: string;
onTabChange?: (id: string) => void;
/** Authentication token for the WebSocket connection */ /** Authentication token for the WebSocket connection */
token?: string; token?: string;
/** Optional CSS class name */
className?: string; className?: string;
} }
const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
// ========================================== // ==========================================
// Component // Component
// ========================================== // ==========================================
@@ -39,14 +42,107 @@ const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
export function TerminalPanel({ export function TerminalPanel({
open, open,
onClose, onClose,
tabs,
activeTab,
onTabChange,
token = "", token = "",
className = "", className = "",
}: TerminalPanelProps): ReactElement { }: TerminalPanelProps): ReactElement {
const resolvedTabs = tabs ?? defaultTabs; const {
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? ""; sessions,
activeSessionId,
isConnected,
connectionError,
createSession,
closeSession,
renameSession,
setActiveSession,
sendInput,
resize,
registerOutputCallback,
} = useTerminalSessions({ token });
// ==========================================
// 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 = { const panelStyle: CSSProperties = {
background: "var(--bg-deep)", background: "var(--bg-deep)",
@@ -71,12 +167,15 @@ export function TerminalPanel({
const tabBarStyle: CSSProperties = { const tabBarStyle: CSSProperties = {
display: "flex", display: "flex",
gap: 2, gap: 2,
alignItems: "center",
flex: 1,
overflow: "hidden",
}; };
const actionsStyle: CSSProperties = { const actionsStyle: CSSProperties = {
marginLeft: "auto",
display: "flex", display: "flex",
gap: 4, gap: 4,
alignItems: "center",
}; };
const bodyStyle: CSSProperties = { const bodyStyle: CSSProperties = {
@@ -85,8 +184,13 @@ export function TerminalPanel({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
minHeight: 0, minHeight: 0,
position: "relative",
}; };
// ==========================================
// Render
// ==========================================
return ( return (
<div <div
className={className} className={className}
@@ -99,50 +203,172 @@ export function TerminalPanel({
<div style={headerStyle}> <div style={headerStyle}>
{/* Tab bar */} {/* Tab bar */}
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs"> <div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
{resolvedTabs.map((tab) => { {[...sessions.entries()].map(([sessionId, sessionInfo]) => {
const isActive = tab.id === resolvedActiveTab; const isActive = sessionId === activeSessionId;
const isEditing = sessionId === editingTabId;
const tabStyle: CSSProperties = { const tabStyle: CSSProperties = {
padding: "3px 10px", display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 6px 3px 10px",
borderRadius: 4, borderRadius: 4,
fontSize: "0.75rem", fontSize: "0.75rem",
fontFamily: "var(--mono)", fontFamily: "var(--mono)",
color: isActive ? "var(--success)" : "var(--muted)", color: isActive ? "var(--success)" : "var(--muted)",
cursor: "pointer",
background: isActive ? "var(--surface)" : "transparent", background: isActive ? "var(--surface)" : "transparent",
border: "none", border: "none",
outline: "none", outline: "none",
flexShrink: 0,
}; };
return ( return (
<button <div key={sessionId} style={tabStyle}>
key={tab.id} {isEditing ? (
role="tab" <input
aria-selected={isActive} ref={editInputRef}
style={tabStyle} value={editingName}
onClick={(): void => { onChange={(e): void => {
onTabChange?.(tab.id); setEditingName(e.target.value);
}} }}
onMouseEnter={(e): void => { onBlur={commitRename}
if (!isActive) { 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 => {
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.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)"; (e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
} }}
}} onMouseLeave={(e): void => {
onMouseLeave={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.background = "transparent"; (e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)"; (e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
} }}
}} >
> <svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
{tab.label} <path
</button> 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>
</div> </div>
{/* Action buttons */} {/* Action buttons */}
<div style={actionsStyle}> <div style={actionsStyle}>
{/* Close panel button */}
<button <button
aria-label="Close terminal" aria-label="Close terminal"
style={{ style={{
@@ -182,9 +408,72 @@ export function TerminalPanel({
</div> </div>
</div> </div>
{/* Terminal body */} {/* 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}> <div style={bodyStyle}>
<XTerminal token={token} isVisible={open} style={{ flex: 1, minHeight: 0 }} /> {[...sessions.entries()].map(([sessionId, sessionInfo]) => {
const isActive = sessionId === activeSessionId;
const termStyle: CSSProperties = {
display: isActive ? "flex" : "none",
flex: 1,
flexDirection: "column",
minHeight: 0,
};
return (
<div key={sessionId} 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>
);
})}
{/* Empty state */}
{sessions.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>
</div> </div>
); );

View File

@@ -4,21 +4,13 @@
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 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"; import type { ReactElement } from "react";
// ========================================== // ==========================================
// Mocks — set up before importing components // 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 // Mock xterm packages — they require a DOM canvas not available in jsdom
const mockTerminalDispose = vi.fn(); const mockTerminalDispose = vi.fn();
const mockTerminalWrite = vi.fn(); const mockTerminalWrite = vi.fn();
@@ -76,26 +68,6 @@ vi.mock("@xterm/addon-web-links", () => ({
// Mock the CSS import // Mock the CSS import
vi.mock("@xterm/xterm/css/xterm.css", () => ({})); 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 // Mock ResizeObserver
const mockObserve = vi.fn(); const mockObserve = vi.fn();
const mockUnobserve = vi.fn(); const mockUnobserve = vi.fn();
@@ -128,6 +100,31 @@ vi.stubGlobal(
import { XTerminal } from "./XTerminal"; 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<Parameters<typeof XTerminal>[0]> = {}
): Parameters<typeof XTerminal>[0] {
return {
sessionId: "session-test",
sendInput: mockSendInput,
resize: mockResize,
closeSession: mockCloseSession,
registerOutputCallback: mockRegisterOutputCallback,
isConnected: false,
sessionStatus: "active" as const,
...overrides,
};
}
// ========================================== // ==========================================
// Tests // Tests
// ========================================== // ==========================================
@@ -135,10 +132,9 @@ import { XTerminal } from "./XTerminal";
describe("XTerminal", () => { describe("XTerminal", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockIsConnected = false;
mockSessionId = null;
mockTerminalCols = 80; mockTerminalCols = 80;
mockTerminalRows = 24; mockTerminalRows = 24;
mockRegisterOutputCallback.mockReturnValue(vi.fn());
}); });
afterEach(() => { afterEach(() => {
@@ -151,66 +147,101 @@ describe("XTerminal", () => {
describe("rendering", () => { describe("rendering", () => {
it("renders the terminal container", () => { it("renders the terminal container", () => {
render((<XTerminal token="test-token" />) as ReactElement); render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument(); expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
}); });
it("renders the xterm viewport div", () => { it("renders the xterm viewport div", () => {
render((<XTerminal token="test-token" />) as ReactElement); render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument(); expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument();
}); });
it("applies the className prop to the container", () => { it("applies the className prop to the container", () => {
render((<XTerminal token="test-token" className="custom-class" />) as ReactElement); render((<XTerminal {...makeDefaultProps()} className="custom-class" />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class"); expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class");
}); });
it("shows connecting message when not connected", () => { it("sets data-session-id on the container", () => {
mockIsConnected = false; render((<XTerminal {...makeDefaultProps({ sessionId: "my-session" })} />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toHaveAttribute(
"data-session-id",
"my-session"
);
});
render((<XTerminal token="test-token" />) as ReactElement); it("shows connecting message when not connected and session is active", () => {
render((<XTerminal {...makeDefaultProps({ isConnected: false })} />) as ReactElement);
expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument(); expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument();
}); });
it("does not show connecting message when connected", async () => { it("does not show connecting message when connected", () => {
mockIsConnected = true; render((<XTerminal {...makeDefaultProps({ isConnected: true })} />) as ReactElement);
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
});
const { useTerminal } = await import("@/hooks/useTerminal"); it("does not show connecting message when session has exited", () => {
vi.mocked(useTerminal).mockReturnValue({ render(
isConnected: true, (
sessionId: "session-xyz", <XTerminal {...makeDefaultProps({ isConnected: false, sessionStatus: "exited" })} />
createSession: mockCreateSession, ) as ReactElement
sendInput: mockSendInput, );
resize: mockResize,
closeSession: mockCloseSession,
connectionError: null,
});
render((<XTerminal token="test-token" />) as ReactElement);
expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument(); expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument();
}); });
}); });
// ========================================== // ==========================================
// useTerminal integration // Exit overlay
// ========================================== // ==========================================
describe("useTerminal integration", () => { describe("exit overlay", () => {
it("passes the token to useTerminal", async () => { it("shows restart button when session has exited", () => {
const { useTerminal } = await import("@/hooks/useTerminal"); render((<XTerminal {...makeDefaultProps({ sessionStatus: "exited" })} />) as ReactElement);
render((<XTerminal token="my-auth-token" />) as ReactElement); expect(screen.getByRole("button", { name: /restart terminal/i })).toBeInTheDocument();
expect(vi.mocked(useTerminal)).toHaveBeenCalledWith(
expect.objectContaining({ token: "my-auth-token" })
);
}); });
it("passes onOutput, onExit, onError callbacks to useTerminal", async () => { it("does not show restart button when session is active", () => {
const { useTerminal } = await import("@/hooks/useTerminal"); render((<XTerminal {...makeDefaultProps({ sessionStatus: "active" })} />) as ReactElement);
render((<XTerminal token="test-token" />) as ReactElement); expect(screen.queryByRole("button", { name: /restart terminal/i })).not.toBeInTheDocument();
const callArgs = vi.mocked(useTerminal).mock.calls[0]?.[0]; });
expect(typeof callArgs?.onOutput).toBe("function");
expect(typeof callArgs?.onExit).toBe("function"); it("shows exit code in restart button when provided", () => {
expect(typeof callArgs?.onError).toBe("function"); render(
(
<XTerminal {...makeDefaultProps({ sessionStatus: "exited", exitCode: 1 })} />
) as ReactElement
);
expect(screen.getByRole("button", { name: /exit 1/i })).toBeInTheDocument();
});
it("calls onRestart when restart button is clicked", () => {
render(
(
<XTerminal {...makeDefaultProps({ sessionStatus: "exited", onRestart: mockOnRestart })} />
) 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((<XTerminal {...makeDefaultProps({ sessionId: "test-session" })} />) 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((<XTerminal {...makeDefaultProps()} />) as ReactElement);
unmount();
expect(unsubscribe).toHaveBeenCalled();
}); });
}); });
@@ -220,8 +251,20 @@ describe("XTerminal", () => {
describe("accessibility", () => { describe("accessibility", () => {
it("has an accessible region role", () => { it("has an accessible region role", () => {
render((<XTerminal token="test-token" />) as ReactElement); render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument(); expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument();
}); });
}); });
// ==========================================
// Visibility
// ==========================================
describe("isVisible", () => {
it("renders with isVisible=true by default", () => {
render((<XTerminal {...makeDefaultProps()} />) as ReactElement);
// Container is present; isVisible affects re-fit timing
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
});
});
}); });

View File

@@ -3,29 +3,55 @@
/** /**
* XTerminal component * XTerminal component
* *
* Renders a real xterm.js terminal connected to the backend /terminal WebSocket namespace. * Renders a real xterm.js terminal. The parent (TerminalPanel via useTerminalSessions)
* Handles resize, copy/paste, theme, and session lifecycle. * 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 type { ReactElement, CSSProperties } from "react";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import type { Terminal as XTerm } from "@xterm/xterm"; import type { Terminal as XTerm } from "@xterm/xterm";
import type { FitAddon as XFitAddon } from "@xterm/addon-fit"; import type { FitAddon as XFitAddon } from "@xterm/addon-fit";
import { useTerminal } from "@/hooks/useTerminal"; import type { SessionStatus } from "@/hooks/useTerminalSessions";
// ========================================== // ==========================================
// Types // Types
// ========================================== // ==========================================
export interface XTerminalProps { export interface XTerminalProps {
/** Authentication token for the WebSocket connection */ /** Session identifier (provided by useTerminalSessions) */
token: string; 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 */ /** Optional CSS class name for the outer container */
className?: string; className?: string;
/** Optional inline styles for the outer container */ /** Optional inline styles for the outer container */
style?: CSSProperties; 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; 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. * 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<string, string> { function buildXtermTheme(): Record<string, string> {
return { return {
@@ -82,11 +106,20 @@ function buildXtermTheme(): Record<string, string> {
// ========================================== // ==========================================
/** /**
* XTerminal renders a real PTY terminal powered by xterm.js, * XTerminal renders a real PTY terminal powered by xterm.js.
* connected to the backend /terminal WebSocket namespace. * The parent provides the sessionId and control functions; this component
* registers for output data and manages the xterm.js instance lifecycle.
*/ */
export function XTerminal({ export function XTerminal({
token, sessionId,
sendInput,
resize,
closeSession: _closeSession,
registerOutputCallback,
isConnected,
sessionStatus,
exitCode,
onRestart,
className = "", className = "",
style, style,
isVisible = true, isVisible = true,
@@ -97,41 +130,7 @@ export function XTerminal({
const resizeObserverRef = useRef<ResizeObserver | null>(null); const resizeObserverRef = useRef<ResizeObserver | null>(null);
const isTerminalMountedRef = useRef(false); const isTerminalMountedRef = useRef(false);
const [hasExited, setHasExited] = useState(false); const hasExited = sessionStatus === "exited";
const [exitCode, setExitCode] = useState<number | null>(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,
});
// ========================================== // ==========================================
// Fit helper // Fit helper
@@ -144,11 +143,11 @@ export function XTerminal({
try { try {
fitAddon.fit(); fitAddon.fit();
resize(terminal.cols, terminal.rows); resize(sessionId, terminal.cols, terminal.rows);
} catch { } catch {
// Ignore fit errors (e.g., when container has zero dimensions) // Ignore fit errors (e.g., when container has zero dimensions)
} }
}, [resize]); }, [resize, sessionId]);
// ========================================== // ==========================================
// Mount xterm.js terminal (client-only) // Mount xterm.js terminal (client-only)
@@ -217,8 +216,20 @@ export function XTerminal({
return (): void => { return (): void => {
cancelled = true; 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 // Re-fit when visibility changes
// ========================================== // ==========================================
@@ -243,39 +254,13 @@ export function XTerminal({
if (!terminal) return; if (!terminal) return;
const disposable = terminal.onData((data: string): void => { const disposable = terminal.onData((data: string): void => {
sendInput(data); sendInput(sessionId, data);
}); });
return (): void => { return (): void => {
disposable.dispose(); disposable.dispose();
}; };
}, [sendInput]); }, [sendInput, sessionId]);
// ==========================================
// 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]);
// ========================================== // ==========================================
// Update xterm theme when data-theme attribute changes // Update xterm theme when data-theme attribute changes
@@ -309,16 +294,26 @@ export function XTerminal({
resizeObserverRef.current?.disconnect(); resizeObserverRef.current?.disconnect();
resizeObserverRef.current = null; resizeObserverRef.current = null;
// Close PTY session
closeSession();
// Dispose xterm terminal // Dispose xterm terminal
terminalRef.current?.dispose(); terminalRef.current?.dispose();
terminalRef.current = null; terminalRef.current = null;
fitAddonRef.current = null; fitAddonRef.current = null;
isTerminalMountedRef.current = false; 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 // Render
@@ -339,8 +334,9 @@ export function XTerminal({
role="region" role="region"
aria-label="Terminal" aria-label="Terminal"
data-testid="xterminal-container" data-testid="xterminal-container"
data-session-id={sessionId}
> >
{/* Status bar */} {/* Status bar — show when not connected and not exited */}
{!isConnected && !hasExited && ( {!isConnected && !hasExited && (
<div <div
style={{ style={{
@@ -385,28 +381,9 @@ export function XTerminal({
border: "1px solid var(--border)", border: "1px solid var(--border)",
cursor: "pointer", cursor: "pointer",
}} }}
onClick={(): void => { onClick={handleRestart}
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 });
}
}}
> >
Restart terminal {exitCode !== null ? `(exit ${exitCode.toString()})` : ""} Restart terminal{exitCode !== undefined ? ` (exit ${exitCode.toString()})` : ""}
</button> </button>
</div> </div>
)} )}

View File

@@ -1,4 +1,4 @@
export type { TerminalTab, TerminalPanelProps } from "./TerminalPanel"; export type { TerminalPanelProps } from "./TerminalPanel";
export { TerminalPanel } from "./TerminalPanel"; export { TerminalPanel } from "./TerminalPanel";
export type { XTerminalProps } from "./XTerminal"; export type { XTerminalProps } from "./XTerminal";
export { XTerminal } from "./XTerminal"; export { XTerminal } from "./XTerminal";

View File

@@ -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<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
emit: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
connected: boolean;
}
describe("useTerminalSessions", () => {
let mockSocket: MockSocket;
let socketEventHandlers: Record<string, (data: unknown) => void>;
let mockIo: ReturnType<typeof vi.fn>;
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");
});
});
});
});

View File

@@ -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<string, SessionInfo>;
/** 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<Socket | null>(null);
// Per-session output callback registry; keyed by sessionId
const outputCallbacksRef = useRef<Map<string, Set<(data: string) => void>>>(new Map());
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map());
const [activeSessionId, setActiveSessionIdState] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(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<string, unknown> = {};
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,
};
}