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

@@ -3,35 +3,38 @@
/**
* TerminalPanel
*
* Shell panel that wraps the XTerminal component with a tab bar and close button.
* Replaces the former mock terminal with a real xterm.js PTY terminal.
* Multi-tab terminal panel. Manages multiple PTY sessions via useTerminalSessions,
* rendering one XTerminal per session and keeping all instances mounted (for
* scrollback preservation) while switching visibility with display:none.
*
* Features:
* - "+" button to open a new tab
* - Per-tab close button
* - Double-click tab label for inline rename
* - Auto-creates the first session on connect
* - Connection error state
*/
import type { ReactElement, CSSProperties } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import type { ReactElement, CSSProperties, KeyboardEvent } from "react";
import { XTerminal } from "./XTerminal";
import { useTerminalSessions } from "@/hooks/useTerminalSessions";
// ==========================================
// Types (retained for backwards compatibility)
// Types
// ==========================================
export interface TerminalTab {
id: string;
label: string;
}
export interface TerminalPanelProps {
/** Whether the panel is visible */
open: boolean;
/** Called when the user closes the panel */
onClose: () => void;
tabs?: TerminalTab[];
activeTab?: string;
onTabChange?: (id: string) => void;
/** Authentication token for the WebSocket connection */
token?: string;
/** Optional CSS class name */
className?: string;
}
const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
// ==========================================
// Component
// ==========================================
@@ -39,14 +42,107 @@ const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
export function TerminalPanel({
open,
onClose,
tabs,
activeTab,
onTabChange,
token = "",
className = "",
}: TerminalPanelProps): ReactElement {
const resolvedTabs = tabs ?? defaultTabs;
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
const {
sessions,
activeSessionId,
isConnected,
connectionError,
createSession,
closeSession,
renameSession,
setActiveSession,
sendInput,
resize,
registerOutputCallback,
} = useTerminalSessions({ token });
// ==========================================
// Inline rename state
// ==========================================
const [editingTabId, setEditingTabId] = useState<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 = {
background: "var(--bg-deep)",
@@ -71,12 +167,15 @@ export function TerminalPanel({
const tabBarStyle: CSSProperties = {
display: "flex",
gap: 2,
alignItems: "center",
flex: 1,
overflow: "hidden",
};
const actionsStyle: CSSProperties = {
marginLeft: "auto",
display: "flex",
gap: 4,
alignItems: "center",
};
const bodyStyle: CSSProperties = {
@@ -85,8 +184,13 @@ export function TerminalPanel({
display: "flex",
flexDirection: "column",
minHeight: 0,
position: "relative",
};
// ==========================================
// Render
// ==========================================
return (
<div
className={className}
@@ -99,50 +203,172 @@ export function TerminalPanel({
<div style={headerStyle}>
{/* Tab bar */}
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
{resolvedTabs.map((tab) => {
const isActive = tab.id === resolvedActiveTab;
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
const isActive = sessionId === activeSessionId;
const isEditing = sessionId === editingTabId;
const tabStyle: CSSProperties = {
padding: "3px 10px",
display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 6px 3px 10px",
borderRadius: 4,
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: isActive ? "var(--success)" : "var(--muted)",
cursor: "pointer",
background: isActive ? "var(--surface)" : "transparent",
border: "none",
outline: "none",
flexShrink: 0,
};
return (
<button
key={tab.id}
role="tab"
aria-selected={isActive}
style={tabStyle}
onClick={(): void => {
onTabChange?.(tab.id);
}}
onMouseEnter={(e): void => {
if (!isActive) {
<div key={sessionId} style={tabStyle}>
{isEditing ? (
<input
ref={editInputRef}
value={editingName}
onChange={(e): void => {
setEditingName(e.target.value);
}}
onBlur={commitRename}
onKeyDown={handleRenameKeyDown}
data-testid="tab-rename-input"
style={{
background: "transparent",
border: "none",
outline: "1px solid var(--primary)",
borderRadius: 2,
fontFamily: "var(--mono)",
fontSize: "0.75rem",
color: "var(--text)",
width: `${Math.max(editingName.length, 4).toString()}ch`,
padding: "0 2px",
}}
aria-label="Rename terminal tab"
/>
) : (
<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.color = "var(--text-2)";
}
}}
onMouseLeave={(e): void => {
if (!isActive) {
(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)";
}
}}
>
{tab.label}
</button>
}}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path
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>
{/* Action buttons */}
<div style={actionsStyle}>
{/* Close panel button */}
<button
aria-label="Close terminal"
style={{
@@ -182,9 +408,72 @@ export function TerminalPanel({
</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}>
<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>
);