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

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #520.
This commit is contained in:
2026-02-26 03:18:35 +00:00
committed by jason.woltje
parent 13aa52aa53
commit 859dcfc4b7
7 changed files with 1913 additions and 300 deletions

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,
};
}