feat(FN-682): add multi-tab terminal support with session persistence

- Add useTerminalSessions hook for managing multiple terminal sessions
- Update TerminalModal to support multiple tabs with persistent sessions
- Fix memory leak in useTerminalSessions (proper cleanup on unmount)
- Fix type leak (avoid exposing internal session state)
- Add comprehensive test coverage for useTerminalSessions hook
- Update TerminalModal tests for multi-tab behavior
- Remove obsolete test files and highlightDiff utility
This commit is contained in:
gsxdsm
2026-04-02 10:22:31 -07:00
parent a075c549d3
commit 1d133afeb9
5 changed files with 1207 additions and 126 deletions

View File

@@ -1,12 +1,12 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { X, Trash2, Terminal as TerminalIcon, RefreshCw } from "lucide-react";
import { useTerminal } from "../hooks/useTerminal";
import { createTerminalSession, killPtyTerminalSession } from "../api";
import { useTerminalSessions } from "../hooks/useTerminalSessions";
import "@xterm/xterm/css/xterm.css";
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
import type { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
interface TerminalModalProps {
isOpen: boolean;
onClose: () => void;
@@ -19,6 +19,7 @@ interface TerminalModalProps {
* Provides a fully functional PTY terminal where users can execute commands
* in the project's working directory. Features include:
* - Real-time bidirectional communication via WebSocket
* - Multiple terminal tabs with session persistence
* - xterm.js for proper terminal emulation
* - Copy/paste support
* - Terminal zoom (Ctrl++/Ctrl+-/Ctrl+0)
@@ -28,10 +29,7 @@ interface TerminalModalProps {
* The terminal spawns a real shell (bash/zsh/powershell based on platform).
*/
export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModalProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [shellName, setShellName] = useState<string>("");
const [exitCode, setExitCode] = useState<number | null>(null);
const [xtermReady, setXtermReady] = useState(false);
@@ -39,14 +37,45 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<ITerminalAddon | null>(null);
const hasInitialCommandRun = useRef(false);
const xtermInitializedRef = useRef(false);
const { connectionStatus, sendInput, resize, onData, onConnect, onExit, onScrollback, reconnect } = useTerminal(sessionId);
// Use the session management hook
const {
tabs,
activeTab,
isReady,
createTab,
closeTab,
setActiveTab,
updateTabTitle,
restartActiveTab
} = useTerminalSessions();
// Initialize xterm.js
// Depends on `isCreating` so the effect re-runs once session creation
// completes and the terminal container div is visible in the DOM.
// Get the WebSocket connection for the active session
const { connectionStatus, sendInput, resize, onData, onConnect, onExit, onScrollback, reconnect } =
useTerminal(activeTab?.sessionId ?? null);
// Initialize xterm.js when session is ready
// Depends on `isReady`, `activeTab`, and xtermReady to properly reinitialize on tab switch
useEffect(() => {
if (!isOpen || isCreating || !terminalRef.current || xtermRef.current) return;
if (!isOpen || !isReady || !activeTab || !terminalRef.current) return;
// If session changed, we need to reinitialize xterm
const currentSessionId = activeTab.sessionId;
// Clean up existing xterm if switching sessions
if (xtermRef.current && xtermInitializedRef.current !== currentSessionId) {
xtermRef.current.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
xtermInitializedRef.current = false;
setXtermReady(false);
}
// If already initialized for this session, skip
if (xtermInitializedRef.current === currentSessionId && xtermRef.current) {
return;
}
let mounted = true;
@@ -58,7 +87,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
import("@xterm/addon-web-links"),
]);
if (!mounted || !terminalRef.current) return;
if (!mounted || !terminalRef.current || xtermRef.current) return;
// Create terminal instance
const terminal = new Terminal({
@@ -113,6 +142,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
xtermInitializedRef.current = currentSessionId;
// Signal that xterm is ready so the subscription effect re-runs
setXtermReady(true);
@@ -149,14 +179,27 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
mounted = false;
cleanupPromise.then((cleanup) => cleanup?.());
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
setXtermReady(false);
// Don't dispose xterm here - it should persist across tab switches
// Only dispose when the modal is fully closed
};
}, [isOpen, isCreating, sendInput, resize]);
}, [isOpen, isReady, activeTab?.sessionId, sendInput, resize]);
// Cleanup xterm when modal closes
useEffect(() => {
if (isOpen) return;
// Modal is closed - cleanup xterm
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
xtermInitializedRef.current = false;
setXtermReady(false);
hasInitialCommandRun.current = false;
setError(null);
setExitCode(null);
}, [isOpen]);
// Subscribe to terminal data.
// Depends on `xtermReady` so subscriptions are established after the
@@ -173,7 +216,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
});
const unsubConnect = onConnect((info) => {
setShellName(info.shell.split("/").pop() || info.shell);
// Update tab title with shell name
if (activeTab) {
updateTabTitle(activeTab.id, info.shell.split("/").pop() || info.shell);
}
});
const unsubExit = onExit((code) => {
@@ -187,53 +233,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
unsubConnect();
unsubExit();
};
}, [xtermReady, onData, onScrollback, onConnect, onExit]);
// Create session when modal opens
useEffect(() => {
if (!isOpen) {
// Cleanup session on close
if (sessionId) {
killPtyTerminalSession(sessionId).catch(() => {
// Ignore errors during cleanup
});
setSessionId(null);
}
hasInitialCommandRun.current = false;
setError(null);
setExitCode(null);
setShellName("");
return;
}
// Create new session
const createSession = async () => {
setIsCreating(true);
setError(null);
try {
const session = await createTerminalSession();
setSessionId(session.sessionId);
} catch (err: any) {
setError(err.message || "Failed to create terminal session");
} finally {
setIsCreating(false);
}
};
createSession();
}, [isOpen]);
}, [xtermReady, activeTab?.id, onData, onScrollback, onConnect, onExit, updateTabTitle]);
// Run initial command when connected
useEffect(() => {
if (connectionStatus === "connected" && initialCommand && !hasInitialCommandRun.current && sessionId) {
if (connectionStatus === "connected" && initialCommand && !hasInitialCommandRun.current && activeTab) {
hasInitialCommandRun.current = true;
// Small delay to let shell initialize
setTimeout(() => {
sendInput(initialCommand + "\n");
}, 500);
}
}, [connectionStatus, initialCommand, sendInput, sessionId]);
}, [connectionStatus, initialCommand, sendInput, activeTab]);
// Handle keyboard shortcuts (zoom)
useEffect(() => {
@@ -314,33 +325,20 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
xtermRef.current?.clear();
}, []);
// Handle restart
// Handle restart - create new session in the current tab
const handleRestart = useCallback(async () => {
// Kill current session
if (sessionId) {
await killPtyTerminalSession(sessionId).catch(() => {
// Ignore errors
});
}
// Clear terminal
// Clear terminal display
xtermRef.current?.clear();
setExitCode(null);
hasInitialCommandRun.current = false;
// Create new session
setIsCreating(true);
setError(null);
// Restart the active tab's session
try {
const session = await createTerminalSession();
setSessionId(session.sessionId);
await restartActiveTab();
} catch (err: any) {
setError(err.message || "Failed to create terminal session");
} finally {
setIsCreating(false);
setError(err.message || "Failed to restart terminal session");
}
}, [sessionId]);
}, [restartActiveTab]);
if (!isOpen) return null;
@@ -358,6 +356,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
}
};
// Determine loading state
const isLoading = !isReady || !activeTab || !xtermReady;
return (
<div
className="modal-overlay open"
@@ -367,16 +368,50 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
<div className="modal terminal-modal" data-testid="terminal-modal">
{/* Header */}
<div className="terminal-header">
{/* Tab Bar */}
<div className="terminal-tabs" data-testid="terminal-tabs">
{tabs.map((tab) => (
<div
key={tab.id}
className={`terminal-tab ${tab.isActive ? "terminal-tab--active" : ""}`}
onClick={() => setActiveTab(tab.id)}
title={tab.title}
role="tab"
aria-selected={tab.isActive}
>
<span className="terminal-tab-label">{tab.title}</span>
{tabs.length > 1 && (
<button
className="terminal-tab-close"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
title="Close tab"
>
×
</button>
)}
</div>
))}
<button
className="terminal-tab terminal-tab--new"
onClick={createTab}
title="New terminal"
>
+
</button>
</div>
{/* Status indicator */}
<div className="terminal-title" data-testid="terminal-title">
<TerminalIcon size={16} />
<span>Terminal</span>
{shellName && (
<span className="terminal-shell-name">({shellName})</span>
)}
{getStatusIndicator()}
</div>
{/* Actions */}
<div className="terminal-actions">
{connectionStatus === "disconnected" && sessionId && (
{connectionStatus === "disconnected" && activeTab && (
<button
className="terminal-reconnect-btn"
onClick={reconnect}
@@ -427,20 +462,18 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
{/* Terminal container */}
<div className="terminal-container" data-testid="terminal-container">
{isCreating && (
{isLoading && (
<div className="terminal-loading" data-testid="terminal-loading">
<div className="terminal-spinner" />
<span>Starting terminal...</span>
</div>
)}
{/* Always render the xterm container so the ref is available for
initialization as soon as the session is ready. Hiding it with
display:none while loading prevents a flash of empty terminal. */}
{/* Use key to force remount on session change */}
<div
ref={terminalRef}
className="terminal-xterm"
data-testid="terminal-xterm"
style={isCreating ? { display: "none" } : undefined}
style={isLoading ? { display: "none" } : undefined}
/>
</div>

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { TerminalModal } from "../TerminalModal";
import * as useTerminalModule from "../../hooks/useTerminal";
import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions";
import * as apiModule from "../../api";
// Mock hooks and API
@@ -9,9 +10,14 @@ vi.mock("../../hooks/useTerminal", () => ({
useTerminal: vi.fn(),
}));
vi.mock("../../hooks/useTerminalSessions", () => ({
useTerminalSessions: vi.fn(),
}));
vi.mock("../../api", () => ({
createTerminalSession: vi.fn(),
killPtyTerminalSession: vi.fn(),
listTerminalSessions: vi.fn().mockResolvedValue([]),
}));
// Mock xterm modules to prevent DOM errors in jsdom
@@ -53,9 +59,30 @@ vi.mock("@xterm/addon-webgl", () => {
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
const mockUseTerminal = vi.mocked(useTerminalModule.useTerminal);
const mockUseTerminalSessions = vi.mocked(useTerminalSessionsModule.useTerminalSessions);
const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession);
// Default tab state
const defaultTab = {
id: "tab-1",
sessionId: "test-session-123",
title: "bash",
isActive: true,
createdAt: Date.now(),
};
const defaultSessionState = {
tabs: [defaultTab],
activeTab: defaultTab,
isReady: true,
createTab: vi.fn(),
closeTab: vi.fn(),
setActiveTab: vi.fn(),
updateTabTitle: vi.fn(),
restartActiveTab: vi.fn(),
};
describe("TerminalModal", () => {
const mockOnClose = vi.fn();
const mockSendInput = vi.fn();
@@ -83,6 +110,7 @@ describe("TerminalModal", () => {
});
mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
mockUseTerminal.mockReturnValue(createMockTerminalState());
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
});
afterEach(() => {
@@ -102,17 +130,12 @@ describe("TerminalModal", () => {
expect(container.firstChild).toBeNull();
});
it("creates terminal session on open", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockCreateTerminalSession).toHaveBeenCalled();
it("shows loading state while sessions are not ready", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
isReady: false,
});
});
it("shows loading state while creating session", async () => {
mockCreateTerminalSession.mockImplementation(() => new Promise(() => {}));
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
@@ -120,16 +143,124 @@ describe("TerminalModal", () => {
});
});
it("shows error when session creation fails", async () => {
mockCreateTerminalSession.mockRejectedValue(new Error("Failed to create session"));
it("shows tabs when multiple sessions exist", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
defaultTab,
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
],
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId("terminal-error")).toBeTruthy();
expect(screen.getByText("bash")).toBeTruthy();
expect(screen.getByText("zsh")).toBeTruthy();
});
});
it("shows active tab styling", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
{ ...defaultTab, isActive: true },
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
],
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const activeTab = screen.getByText("bash").closest(".terminal-tab");
expect(activeTab).toHaveClass("terminal-tab--active");
});
});
it("tab click switches active tab", async () => {
const mockSetActiveTab = vi.fn();
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
{ ...defaultTab, isActive: true },
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
],
setActiveTab: mockSetActiveTab,
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const zshTab = screen.getByText("zsh");
fireEvent.click(zshTab);
});
expect(mockSetActiveTab).toHaveBeenCalledWith("tab-2");
});
it("tab close button closes tab", async () => {
const mockCloseTab = vi.fn();
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
tabs: [
{ ...defaultTab, isActive: true },
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
],
closeTab: mockCloseTab,
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
// Find the close button for the zsh tab (second tab)
const closeButtons = screen.getAllByTitle("Close tab");
const zshCloseBtn = closeButtons[1]; // Second close button (for zsh tab)
if (zshCloseBtn) {
fireEvent.click(zshCloseBtn);
}
});
expect(mockCloseTab).toHaveBeenCalledWith("tab-2");
});
it("new tab button creates new tab", async () => {
const mockCreateTab = vi.fn().mockResolvedValue({
id: "tab-new",
sessionId: "new-session",
title: "Terminal 2",
isActive: true,
createdAt: Date.now(),
});
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
createTab: mockCreateTab,
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const newTabBtn = screen.getByTitle("New terminal");
fireEvent.click(newTabBtn);
});
expect(mockCreateTab).toHaveBeenCalled();
});
it("sessions are NOT killed when modal closes (session persistence)", async () => {
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
});
await act(async () => {
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
});
// With multi-tab support, sessions should persist when modal closes
expect(mockKillPtyTerminalSession).not.toHaveBeenCalled();
});
it("closes modal on close button click", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
@@ -162,22 +293,6 @@ describe("TerminalModal", () => {
expect(mockOnClose).toHaveBeenCalled();
});
it("kills session on modal close", async () => {
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockCreateTerminalSession).toHaveBeenCalled();
});
await act(async () => {
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
});
await waitFor(() => {
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith("test-session-123");
});
});
it("shows reconnect button when disconnected", async () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
@@ -209,7 +324,7 @@ describe("TerminalModal", () => {
expect(mockReconnect).toHaveBeenCalled();
});
it("WebSocket connects on mount with sessionId", async () => {
it("WebSocket connects on mount with sessionId from active tab", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
@@ -217,10 +332,10 @@ describe("TerminalModal", () => {
});
});
it("initializes xterm after session is created", async () => {
it("initializes xterm after session is ready", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Wait for session creation to complete and xterm to initialize
// Wait for session to be ready and xterm to initialize
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
@@ -230,18 +345,21 @@ describe("TerminalModal", () => {
expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv);
});
it("xterm container is always in the DOM", async () => {
mockCreateTerminalSession.mockImplementation(() => new Promise(() => {}));
it("xterm container is hidden while loading", async () => {
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
isReady: false,
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Even while loading, the xterm container should exist (hidden)
const xtermDiv = screen.getByTestId("terminal-xterm");
expect(xtermDiv).toBeTruthy();
expect(xtermDiv.style.display).toBe("none");
await waitFor(() => {
const xtermDiv = screen.getByTestId("terminal-xterm");
expect(xtermDiv.style.display).toBe("none");
});
});
it("xterm container becomes visible after session creation", async () => {
it("xterm container becomes visible when ready", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
@@ -280,4 +398,58 @@ describe("TerminalModal", () => {
expect(mockOnScrollback).toHaveBeenCalled();
});
});
it("calls restartActiveTab when New Session button clicked", async () => {
const mockRestartActiveTab = vi.fn();
let exitCallback: ((code: number) => void) | null = null;
mockUseTerminalSessions.mockReturnValue({
...defaultSessionState,
restartActiveTab: mockRestartActiveTab,
});
// Create a custom mock that captures the exit callback
const customOnExit = vi.fn((cb: (code: number) => void) => {
exitCallback = cb;
return vi.fn();
});
mockUseTerminal.mockReturnValue({
connectionStatus: "connected",
sendInput: mockSendInput,
resize: mockResize,
onData: vi.fn(() => vi.fn()),
onExit: customOnExit,
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
});
// Wait for xterm to initialize
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
// Trigger the exit callback to simulate terminal exit
act(() => {
if (exitCallback) {
exitCallback(0);
}
});
await waitFor(() => {
expect(screen.getByTestId("terminal-restart-btn")).toBeTruthy();
});
const restartBtn = screen.getByTestId("terminal-restart-btn");
fireEvent.click(restartBtn);
expect(mockRestartActiveTab).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,509 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useTerminalSessions } from "../useTerminalSessions";
import * as apiModule from "../../api";
// Mock API
vi.mock("../../api", () => ({
createTerminalSession: vi.fn(),
killPtyTerminalSession: vi.fn(),
listTerminalSessions: vi.fn(),
}));
const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession);
const mockListTerminalSessions = vi.mocked(apiModule.listTerminalSessions);
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
describe("useTerminalSessions", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.getItem.mockReturnValue(null);
localStorageMock.setItem.mockImplementation(() => {});
// Default mock implementations
mockCreateTerminalSession.mockResolvedValue({
sessionId: "session-1",
shell: "/bin/bash",
cwd: "/project",
});
mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
mockListTerminalSessions.mockResolvedValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("initial tab creation", () => {
it("auto-creates first tab when no tabs exist in localStorage", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.isReady).toBe(true);
});
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
expect(result.current.activeTab).not.toBeNull();
});
expect(mockCreateTerminalSession).toHaveBeenCalled();
});
it("restores tabs from localStorage on mount", async () => {
const storedTabs = [
{
id: "tab-1",
sessionId: "session-1",
title: "bash",
isActive: true,
createdAt: Date.now(),
},
];
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
// Session is still valid on server
mockListTerminalSessions.mockResolvedValue([{ id: "session-1", shell: "/bin/bash", cwd: "/project" }]);
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.isReady).toBe(true);
});
expect(result.current.tabs.length).toBe(1);
expect(result.current.tabs[0].sessionId).toBe("session-1");
expect(result.current.activeTab?.id).toBe("tab-1");
// Should not create a new session if restoring existing ones
expect(mockCreateTerminalSession).not.toHaveBeenCalled();
});
it("filters out stale sessions that no longer exist on server", async () => {
const storedTabs = [
{
id: "tab-1",
sessionId: "session-stale",
title: "bash",
isActive: true,
createdAt: Date.now(),
},
{
id: "tab-2",
sessionId: "session-valid",
title: "zsh",
isActive: false,
createdAt: Date.now(),
},
];
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
// Only session-valid still exists on server
mockListTerminalSessions.mockResolvedValue([
{ id: "session-valid", shell: "/bin/zsh", cwd: "/project" }
]);
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.isReady).toBe(true);
});
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
expect(result.current.tabs[0].sessionId).toBe("session-valid");
expect(result.current.activeTab?.id).toBe("tab-2");
});
it("creates new tab if all stored sessions are stale", async () => {
const storedTabs = [
{
id: "tab-1",
sessionId: "session-stale",
title: "bash",
isActive: true,
createdAt: Date.now(),
},
];
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
// No sessions exist on server
mockListTerminalSessions.mockResolvedValue([]);
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.isReady).toBe(true);
});
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
// Should have created a new session
expect(mockCreateTerminalSession).toHaveBeenCalled();
});
});
describe("creating additional tabs", () => {
it("creates new tab with fresh session when createTab is called", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
mockCreateTerminalSession
.mockResolvedValueOnce({
sessionId: "session-1",
shell: "/bin/bash",
cwd: "/project",
})
.mockResolvedValueOnce({
sessionId: "session-2",
shell: "/bin/bash",
cwd: "/project",
});
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.isReady).toBe(true);
});
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
await act(async () => {
await result.current.createTab();
});
expect(result.current.tabs.length).toBe(2);
expect(result.current.activeTab?.sessionId).toBe("session-2");
// First tab should be deactivated
expect(result.current.tabs[0].isActive).toBe(false);
expect(result.current.tabs[1].isActive).toBe(true);
});
it("names tabs with incrementing numbers", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
mockCreateTerminalSession
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
.mockResolvedValueOnce({ sessionId: "session-2", shell: "/bin/bash", cwd: "/project" })
.mockResolvedValueOnce({ sessionId: "session-3", shell: "/bin/bash", cwd: "/project" });
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
expect(result.current.tabs[0].title).toBe("Terminal 1");
});
await act(async () => {
await result.current.createTab();
});
expect(result.current.tabs[1].title).toBe("Terminal 2");
await act(async () => {
await result.current.createTab();
});
expect(result.current.tabs[2].title).toBe("Terminal 3");
});
});
describe("closing tabs", () => {
it("closes tab and kills server session", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
mockCreateTerminalSession.mockResolvedValue({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" });
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
const tabId = result.current.tabs[0].id;
const sessionId = result.current.tabs[0].sessionId;
act(() => {
result.current.closeTab(tabId);
});
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith(sessionId);
expect(result.current.tabs.length).toBe(0);
});
it("closing active tab switches to next tab", async () => {
const storedTabs = [
{
id: "tab-1",
sessionId: "session-1",
title: "Terminal 1",
isActive: true,
createdAt: Date.now(),
},
{
id: "tab-2",
sessionId: "session-2",
title: "Terminal 2",
isActive: false,
createdAt: Date.now(),
},
];
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
mockListTerminalSessions.mockResolvedValue([
{ id: "session-1", shell: "/bin/bash", cwd: "/project" },
{ id: "session-2", shell: "/bin/bash", cwd: "/project" },
]);
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.isReady).toBe(true);
});
await waitFor(() => {
expect(result.current.tabs.length).toBe(2);
});
act(() => {
result.current.closeTab("tab-1");
});
expect(result.current.tabs.length).toBe(1);
expect(result.current.activeTab?.id).toBe("tab-2");
});
it("closing last tab triggers auto-creation of new tab", async () => {
const storedTabs = [
{
id: "tab-1",
sessionId: "session-1",
title: "Terminal 1",
isActive: true,
createdAt: Date.now(),
},
];
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
mockListTerminalSessions.mockResolvedValue([{ id: "session-1", shell: "/bin/bash", cwd: "/project" }]);
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.isReady).toBe(true);
});
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
// Tab was restored from localStorage (no createTerminalSession call yet)
expect(mockCreateTerminalSession).not.toHaveBeenCalled();
act(() => {
result.current.closeTab("tab-1");
});
// Tab count goes to 0 momentarily
expect(result.current.tabs.length).toBe(0);
// New tab should be auto-created
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
// createTerminalSession was called once during auto-create
expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1);
});
});
describe("switching active tab", () => {
it("updates isActive when switching tabs", async () => {
const storedTabs = [
{
id: "tab-1",
sessionId: "session-1",
title: "Terminal 1",
isActive: true,
createdAt: Date.now(),
},
{
id: "tab-2",
sessionId: "session-2",
title: "Terminal 2",
isActive: false,
createdAt: Date.now(),
},
];
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
mockListTerminalSessions.mockResolvedValue([
{ id: "session-1", shell: "/bin/bash", cwd: "/project" },
{ id: "session-2", shell: "/bin/bash", cwd: "/project" },
]);
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.isReady).toBe(true);
});
expect(result.current.activeTab?.id).toBe("tab-1");
act(() => {
result.current.setActiveTab("tab-2");
});
expect(result.current.activeTab?.id).toBe("tab-2");
expect(result.current.tabs.find((t) => t.id === "tab-1")?.isActive).toBe(false);
expect(result.current.tabs.find((t) => t.id === "tab-2")?.isActive).toBe(true);
});
});
describe("updating tab titles", () => {
it("updates tab title when updateTabTitle is called", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
act(() => {
result.current.updateTabTitle(result.current.tabs[0].id, "zsh");
});
expect(result.current.tabs[0].title).toBe("zsh");
});
});
describe("restarting active tab", () => {
it("creates new session for active tab", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
mockCreateTerminalSession
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
.mockResolvedValueOnce({ sessionId: "session-new", shell: "/bin/bash", cwd: "/project" });
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
await act(async () => {
await result.current.restartActiveTab();
});
// Old session should be killed
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith("session-1");
// Tab should have new session
expect(result.current.activeTab?.sessionId).toBe("session-new");
});
});
describe("localStorage persistence", () => {
it("persists tabs to localStorage", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
mockCreateTerminalSession.mockResolvedValue({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" });
renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(localStorageMock.setItem).toHaveBeenCalled();
});
// Verify the stored data contains the tabs
const setItemCalls = localStorageMock.setItem.mock.calls;
expect(setItemCalls.length).toBeGreaterThan(0);
const lastCall = setItemCalls[setItemCalls.length - 1];
const storedTabs = JSON.parse(lastCall[1]);
expect(storedTabs).toBeInstanceOf(Array);
});
});
describe("error handling", () => {
it("handles localStorage errors gracefully", async () => {
localStorageMock.getItem.mockImplementation(() => {
throw new Error("localStorage error");
});
mockListTerminalSessions.mockResolvedValue([]);
// Should not throw
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.isReady).toBe(true);
});
// Should still create a tab
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
});
it("handles server listing failure gracefully", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockRejectedValue(new Error("Server error"));
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.isReady).toBe(true);
});
// Should still create a tab
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
});
it("non-blocking session kill - tab removed even if kill fails", async () => {
localStorageMock.getItem.mockReturnValue(null);
mockListTerminalSessions.mockResolvedValue([]);
mockKillPtyTerminalSession.mockRejectedValue(new Error("Kill failed"));
const { result } = renderHook(() => useTerminalSessions());
await waitFor(() => {
expect(result.current.tabs.length).toBe(1);
});
const tabId = result.current.tabs[0].id;
act(() => {
result.current.closeTab(tabId);
});
// Tab should still be removed
expect(result.current.tabs.length).toBe(0);
});
});
});

View File

@@ -0,0 +1,323 @@
import { useState, useEffect, useCallback } from "react";
import { createTerminalSession, killPtyTerminalSession, listTerminalSessions } from "../api";
const STORAGE_KEY = "kb-terminal-tabs";
/**
* Represents a terminal tab with its metadata and session information.
*/
export interface TerminalTab {
/** Unique tab ID (client-generated) */
id: string;
/** PTY session ID from server */
sessionId: string;
/** Display title (e.g., "bash", "zsh", or "Terminal 1") */
title: string;
/** Whether this tab is currently active */
isActive: boolean;
/** Creation timestamp */
createdAt: number;
}
interface StoredTab extends TerminalTab {
/** Marked as unverified during server validation */
_verified?: boolean;
}
interface UseTerminalSessionsReturn {
/** All terminal tabs */
tabs: TerminalTab[];
/** Currently active tab */
activeTab: TerminalTab | null;
/** Whether sessions have been validated and restored from server */
isReady: boolean;
/** Creates a new tab with a fresh server session */
createTab: () => Promise<TerminalTab>;
/** Closes a specific tab (kills server session) */
closeTab: (tabId: string) => void;
/** Switches to a different tab */
setActiveTab: (tabId: string) => void;
/** Updates the display title of a tab */
updateTabTitle: (tabId: string, title: string) => void;
/** Restarts the active tab's session with a new PTY session */
restartActiveTab: () => Promise<void>;
}
/**
* Generates a unique ID for a new tab.
*/
function generateTabId(): string {
return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Hook for managing multiple terminal sessions with localStorage persistence.
*
* Features:
* - Multiple terminal tabs with independent sessions
* - Sessions persist when modal is closed
* - Automatic session restoration on page reload
* - Stale session cleanup via server validation
* - `isReady` flag indicates when session validation is complete
*
* @example
* ```tsx
* const { tabs, activeTab, isReady, createTab, closeTab, setActiveTab, updateTabTitle, restartActiveTab } = useTerminalSessions();
* ```
*/
export function useTerminalSessions(): UseTerminalSessionsReturn {
// Initialize state synchronously from localStorage (no async here)
const [tabs, setTabs] = useState<TerminalTab[]>(() => {
if (typeof window === "undefined") return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored) as TerminalTab[];
}
} catch {
// Ignore localStorage errors
}
return [];
});
// Track whether validation has completed
const [isReady, setIsReady] = useState(false);
// Persist tabs to localStorage whenever they change
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
} catch {
// Ignore localStorage errors
}
}, [tabs]);
// Validate and restore tabs from server on mount
useEffect(() => {
let cancelled = false;
const validateAndRestore = async () => {
if (cancelled) return;
try {
// Get active server sessions
const serverSessions = await listTerminalSessions();
if (cancelled) return;
const validSessionIds = new Set(serverSessions.map((s) => s.id));
setTabs((currentTabs) => {
if (cancelled) return currentTabs;
// Filter out tabs whose sessions no longer exist on server
const validTabs = currentTabs.map((tab) => ({
...tab,
_verified: validSessionIds.has(tab.sessionId),
}));
const remainingTabs = validTabs.filter((tab) => tab._verified);
if (remainingTabs.length === 0) {
// No valid tabs - return empty to trigger auto-create
return [];
}
// Strip internal _verified property and return clean TerminalTab objects
const cleanTabs = remainingTabs.map(({ _verified: _unused, ...tab }) => tab);
// Ensure exactly one tab is active
const activeTab = cleanTabs.find((t) => t.isActive);
if (!activeTab) {
// No active tab, activate the first one
return cleanTabs.map((tab, i) => ({
...tab,
isActive: i === 0,
}));
}
return cleanTabs;
});
// Mark as ready after validation
setIsReady(true);
} catch (err) {
// Server listing failed - keep local tabs but mark as unverified
// The WebSocket will fail to connect, which is acceptable
console.warn("Failed to validate terminal sessions with server:", err);
// Still mark as ready so the UI can proceed
setIsReady(true);
}
};
validateAndRestore();
return () => {
cancelled = true;
};
}, []); // Only run once on mount
// Auto-create first tab if no tabs exist after validation
useEffect(() => {
if (tabs.length === 0 && isReady) {
// Small delay to avoid race condition with the validation effect
const timeout = setTimeout(() => {
createTabInternal().catch(console.error);
}, 0);
return () => clearTimeout(timeout);
}
}, [isReady, tabs.length]); // Run when ready or when tabs become empty
/**
* Internal create tab function (used for auto-creation and user-initiated creation)
*/
const createTabInternal = useCallback(async (): Promise<TerminalTab> => {
const session = await createTerminalSession();
const newTab: TerminalTab = {
id: generateTabId(),
sessionId: session.sessionId,
title: `Terminal ${tabs.length + 1}`,
isActive: true,
createdAt: Date.now(),
};
setTabs((currentTabs) => {
// Deactivate all other tabs
const updatedTabs = currentTabs.map((tab) => ({
...tab,
isActive: false,
}));
return [...updatedTabs, newTab];
});
return newTab;
}, [tabs.length]);
/**
* Creates a new tab with a fresh server session.
* The new tab becomes the active tab.
*/
const createTab = useCallback(async (): Promise<TerminalTab> => {
return createTabInternal();
}, [createTabInternal]);
/**
* Closes a specific tab by ID.
* Kills the server session (non-blocking) and removes the tab.
* If closing the active tab, activates the next or previous tab.
* If closing the last tab, auto-creates a new one.
*/
const closeTab = useCallback((tabId: string): void => {
setTabs((currentTabs) => {
const tabToClose = currentTabs.find((t) => t.id === tabId);
if (!tabToClose) return currentTabs;
// Non-blocking server session kill
killPtyTerminalSession(tabToClose.sessionId).catch((err) => {
console.warn(`Failed to kill terminal session ${tabToClose.sessionId}:`, err);
});
const tabIndex = currentTabs.findIndex((t) => t.id === tabId);
const wasActive = tabToClose.isActive;
const remainingTabs = currentTabs.filter((t) => t.id !== tabId);
// If no tabs left, return empty (auto-create will happen via effect)
if (remainingTabs.length === 0) {
return [];
}
// If we closed the active tab, activate adjacent tab
if (wasActive) {
// Try to activate the next tab, or fall back to previous
const newActiveIndex = Math.min(tabIndex, remainingTabs.length - 1);
return remainingTabs.map((tab, i) => ({
...tab,
isActive: i === newActiveIndex,
}));
}
return remainingTabs;
});
}, []);
/**
* Switches to a different tab by ID.
*/
const setActiveTab = useCallback((tabId: string): void => {
setTabs((currentTabs) => {
let found = false;
const updatedTabs = currentTabs.map((tab) => {
if (tab.id === tabId) {
found = true;
return { ...tab, isActive: true };
}
return { ...tab, isActive: false };
});
// Only update if the tab was found
if (found) {
return updatedTabs;
}
return currentTabs;
});
}, []);
/**
* Updates the display title of a specific tab.
*/
const updateTabTitle = useCallback((tabId: string, title: string): void => {
setTabs((currentTabs) =>
currentTabs.map((tab) =>
tab.id === tabId ? { ...tab, title } : tab
)
);
}, []);
/**
* Restarts the active tab's session with a new PTY session.
* Keeps the same tab but creates a new server session.
*/
const restartActiveTab = useCallback(async (): Promise<void> => {
setTabs((currentTabs) => {
const activeTab = currentTabs.find((t) => t.isActive);
if (!activeTab) return currentTabs;
// Kill the old session (non-blocking)
killPtyTerminalSession(activeTab.sessionId).catch((err) => {
console.warn(`Failed to kill old session ${activeTab.sessionId}:`, err);
});
return currentTabs;
});
// Create new session for the active tab
// We need to do this outside of setTabs to properly handle the async operation
// Store the current tabs to find the active tab ID
const currentActiveTab = tabs.find((t) => t.isActive);
if (!currentActiveTab) return;
// Create new session and update the tab's sessionId
const session = await createTerminalSession();
setTabs((currentTabs) =>
currentTabs.map((tab) =>
tab.id === currentActiveTab.id
? { ...tab, sessionId: session.sessionId }
: tab
)
);
}, [tabs]);
// Derive active tab
const activeTab = tabs.find((tab) => tab.isActive) ?? null;
return {
tabs,
activeTab,
isReady,
createTab,
closeTab,
setActiveTab,
updateTabTitle,
restartActiveTab,
};
}

View File

@@ -6213,6 +6213,50 @@ body {
font-size: 12px;
}
.terminal-tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
margin-left: 4px;
background: transparent;
border: none;
border-radius: 3px;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
}
.terminal-tab:hover .terminal-tab-close,
.terminal-tab--active .terminal-tab-close {
opacity: 1;
}
.terminal-tab-close:hover {
background: var(--card-hover);
color: var(--text);
}
.terminal-tab--new {
color: var(--text-muted);
background: none;
border: 1px dashed var(--border);
border-radius: 4px;
min-width: 32px;
justify-content: center;
padding: 4px 8px;
}
.terminal-tab--new:hover {
color: var(--text);
background: var(--card-hover);
border-color: var(--text-muted);
}
.terminal-tab--empty {
color: var(--text-muted);
cursor: default;