refactor: organize frontend components into features/ directories

Moved 43 component files into 9 feature domains:
- features/git/ — commit-dialog, commit-panel, file-editor, git-mount-editor,
  git-toolbar, merge-dialog
- features/project/ — repositories-settings-tab, repository-create-dialog
- features/terminal/ — special-keys-panel, special-keys-strip,
  terminal-session-tabs, terminal
- features/workspace/ — workspace-card, workspace-create-form,
  workspace-header, workspace-instance-chips
- features/session/ — create-session-form, session-card, session-list
- features/tool/ — instance-list, manifest-editor, start-tool-fab,
  start-tool-modal, tool-starter, tools-bottom-sheet
- features/notification/ — event-toast-bridge, notification-center,
  notification-item
- features/settings/ — settings-tab-layout
- features/mobile/ — mobile-action-sheet, mobile-detail-view, mobile-edit-view,
  mobile-fab, mobile-list-view, mobile-nav, mobile-page-header,
  mobile-terminal-header, mobile-terminal-wrapper

Updated all imports across pages and components.
Root components/ now only contains generic UI pieces:
app-shell, code-editor, data-states, icon, protected-route, syntax-highlighter.

Quality gates: verified no remaining old imports.
This commit is contained in:
2026-06-04 12:37:24 +02:00
parent 7224afafd1
commit 1021d61be3
54 changed files with 31 additions and 31 deletions
@@ -0,0 +1,114 @@
import React from "react";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
interface SpecialKeysPanelProps {
onSend: (data: string) => void;
isOpen: boolean;
onClose: () => void;
onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
}
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
{ key: "home", label: "Home" },
{ key: "end", label: "End" },
{ key: "pageup", label: "PgUp" },
{ key: "pagedown", label: "PgDn" },
{ key: "ctrlc", label: "Ctrl+C" },
{ key: "ctrld", label: "Ctrl+D" },
{ key: "ctrlz", label: "Ctrl+Z" },
];
const F_KEYS: { key: SpecialKey; label: string }[] = [
{ key: "f1", label: "F1" },
{ key: "f2", label: "F2" },
{ key: "f3", label: "F3" },
{ key: "f4", label: "F4" },
{ key: "f5", label: "F5" },
{ key: "f6", label: "F6" },
{ key: "f7", label: "F7" },
{ key: "f8", label: "F8" },
{ key: "f9", label: "F9" },
{ key: "f10", label: "F10" },
{ key: "f11", label: "F11" },
{ key: "f12", label: "F12" },
];
export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
onSend,
isOpen,
onClose,
onKeepFocus,
activeModifier,
onModifierChange,
}) => {
if (!isOpen) return null;
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault();
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
onClose();
// Always refocus terminal after sending
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
const handleOverlayPointerDown = (e: React.PointerEvent) => {
e.preventDefault();
onModifierChange(null);
onClose();
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
return (
<div
className="special-keys-panel-overlay"
onPointerDown={handleOverlayPointerDown}
>
<div
className="special-keys-panel"
onPointerDown={(e) => e.stopPropagation()}
>
<div className="special-keys-panel-section">
{EXPANDED_KEYS.map(({ key, label }) => (
<button
key={key}
className="special-key-button"
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
>
{label}
</button>
))}
</div>
<div className="special-keys-panel-divider" />
<div className="special-keys-panel-section">
{F_KEYS.map(({ key, label }) => (
<button
key={key}
className="special-key-button"
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
>
{label}
</button>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,96 @@
import React from "react";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
interface SpecialKeysStripProps {
onSend: (data: string) => void;
isVisible: boolean;
onMoreClick?: () => void;
onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
}
const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [
{ key: "escape", label: "Esc" },
{ key: "tab", label: "Tab" },
{ key: "ctrl", label: "Ctrl", isModifier: true },
{ key: "alt", label: "Alt", isModifier: true },
{ key: "up", label: "↑" },
{ key: "down", label: "↓" },
{ key: "left", label: "←" },
{ key: "right", label: "→" },
];
export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
onSend,
isVisible,
onMoreClick,
onKeepFocus,
activeModifier,
onModifierChange,
}) => {
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault();
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
onModifierChange(activeModifier === key ? null : key);
requestAnimationFrame(() => {
onKeepFocus?.();
});
return;
}
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
// Always refocus terminal after sending
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
const handleMorePointerDown = (e: React.PointerEvent) => {
e.preventDefault();
onMoreClick?.();
requestAnimationFrame(() => {
onKeepFocus?.();
});
};
return (
<div className={`special-keys-strip ${isVisible ? "visible" : "hidden"}`}>
{PRIMARY_KEYS.map(({ key, label, isModifier }) => (
<button
key={key}
className={`special-key-button ${
isModifier && activeModifier === key ? "active-modifier" : ""
}`}
onPointerDown={(e) => handlePointerDown(e, key)}
type="button"
tabIndex={-1}
aria-label={`Send ${label}`}
aria-pressed={isModifier && activeModifier === key}
>
{label}
</button>
))}
{onMoreClick && (
<button
className="special-key-button special-key-more"
onPointerDown={handleMorePointerDown}
type="button"
tabIndex={-1}
aria-label="More special keys"
>
More
</button>
)}
</div>
);
};
@@ -0,0 +1,161 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "./terminal-session-tabs";
const mockSessions: TerminalSessionInfo[] = [
{ id: "s1", name: "Session 1", status: "connected" },
{ id: "s2", name: "Session 2", status: "connecting" },
{ id: "s3", name: "Session 3", status: "disconnected" },
];
afterEach(() => {
cleanup();
});
describe("TerminalSessionTabs", () => {
it("renders all tabs", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
expect(screen.getByText("Session 1")).toBeInTheDocument();
expect(screen.getByText("Session 2")).toBeInTheDocument();
expect(screen.getByText("Session 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /new session/i })).toBeInTheDocument();
});
it("clicking a tab calls onSelect", () => {
const onSelect = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={onSelect}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
fireEvent.click(screen.getAllByText("Session 2")[0]);
expect(onSelect).toHaveBeenCalledWith("s2");
});
it("close button calls onClose after confirmation", () => {
const onClose = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={onClose}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const closeButton = screen.getByLabelText("Close session Session 1");
// First click shows confirm
fireEvent.click(closeButton);
expect(screen.getByText("Close?")).toBeInTheDocument();
// Click confirm text
fireEvent.click(screen.getByText("Close?"));
expect(onClose).toHaveBeenCalledWith("s1");
});
it("double-click enables rename and Enter commits", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith("s1", "Renamed");
});
it("double-click enables rename and Escape cancels", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByText("Session 1")).toBeInTheDocument();
});
it("plus button is disabled at 5 sessions", () => {
const fiveSessions: TerminalSessionInfo[] = Array.from({ length: 5 }, (_, i) => ({
id: `s${i + 1}`,
name: `Session ${i + 1}`,
status: "connected",
}));
render(
<TerminalSessionTabs
sessions={fiveSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const newButton = screen.getByRole("button", { name: /new session/i });
expect(newButton).toBeDisabled();
});
it("status dot reflects connection state", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const tabs = screen.getAllByRole("tab");
expect(tabs).toHaveLength(3);
expect(tabs[0].querySelector(".connected")).toBeInTheDocument();
expect(tabs[1].querySelector(".connecting")).toBeInTheDocument();
expect(tabs[2].querySelector(".disconnected")).toBeInTheDocument();
});
});
@@ -0,0 +1,167 @@
import React, { useState, useRef, useCallback } from "react";
export interface TerminalSessionInfo {
id: string;
name: string;
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
export interface TerminalSessionTabsProps {
sessions: TerminalSessionInfo[];
activeSessionId: string;
onSelect: (sessionId: string) => void;
onClose: (sessionId: string) => void;
onCreate: () => void;
onRename: (sessionId: string, newName: string) => void;
isMobile?: boolean;
}
export const TerminalSessionTabs: React.FC<TerminalSessionTabsProps> = ({
sessions,
activeSessionId,
onSelect,
onClose,
onCreate,
onRename,
isMobile = false,
}) => {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [confirmCloseId, setConfirmCloseId] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const handleDoubleClick = useCallback((session: TerminalSessionInfo) => {
setRenamingId(session.id);
setRenameValue(session.name);
requestAnimationFrame(() => {
renameInputRef.current?.focus();
renameInputRef.current?.select();
});
}, []);
const commitRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
onRename(renamingId, renameValue.trim());
}
setRenamingId(null);
setRenameValue("");
}, [renamingId, renameValue, onRename]);
const cancelRename = useCallback(() => {
setRenamingId(null);
setRenameValue("");
}, []);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
commitRename();
} else if (e.key === "Escape") {
cancelRename();
}
},
[commitRename, cancelRename],
);
const handleCloseClick = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
if (confirmCloseId === sessionId) {
setConfirmCloseId(null);
onClose(sessionId);
} else {
setConfirmCloseId(sessionId);
// Auto-dismiss confirm after 3s
setTimeout(() => {
setConfirmCloseId((prev) => (prev === sessionId ? null : prev));
}, 3000);
}
},
[confirmCloseId, onClose],
);
const isMaxSessions = sessions.length >= 5;
return (
<div
className={`terminal-session-tabs ${isMobile ? "mobile" : ""}`}
role="tablist"
aria-label="Terminal sessions"
>
<div className="terminal-session-tabs-scroll" ref={scrollRef}>
{sessions.map((session) => {
const isActive = session.id === activeSessionId;
const isRenaming = renamingId === session.id;
const isConfirmingClose = confirmCloseId === session.id;
return (
<div
key={session.id}
className={`terminal-session-tab ${isActive ? "active" : ""}`}
role="tab"
aria-selected={isActive}
onClick={() => onSelect(session.id)}
onDoubleClick={() => handleDoubleClick(session)}
title={isRenaming ? "" : `${session.name} (${session.status})`}
>
<span
className={`terminal-session-tab-status ${session.status}`}
aria-hidden="true"
/>
{isRenaming ? (
<input
ref={renameInputRef}
className="terminal-session-tab-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={commitRename}
onClick={(e) => e.stopPropagation()}
aria-label="Rename session"
/>
) : (
<span className="terminal-session-tab-name">
{session.name}
</span>
)}
{isConfirmingClose ? (
<button
className="terminal-session-tab-confirm"
onClick={(e) => {
e.stopPropagation();
setConfirmCloseId(null);
onClose(session.id);
}}
type="button"
>
Close?
</button>
) : (
<button
className="terminal-session-tab-close"
onClick={(e) => handleCloseClick(e, session.id)}
type="button"
aria-label={`Close session ${session.name}`}
tabIndex={-1}
>
×
</button>
)}
</div>
);
})}
<button
className="terminal-session-tab new-session"
onClick={onCreate}
disabled={isMaxSessions}
type="button"
aria-label="New session"
title={isMaxSessions ? "Maximum 5 sessions reached" : "New session"}
>
+
</button>
</div>
</div>
);
};
@@ -0,0 +1,866 @@
import React, {
useEffect,
useImperativeHandle,
useRef,
useState,
useCallback,
} from "react";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links";
import { WebglAddon } from "xterm-addon-webgl";
import "xterm/css/xterm.css";
import {
applyModifierToChar,
type ModifierKey,
} from "../hooks/use-special-keys";
export interface TerminalProps {
instanceId: string;
sessionId?: string;
onClose?: () => void;
isMobile?: boolean;
showControls?: boolean;
activeModifier?: ModifierKey | null;
onModifierChange?: (modifier: ModifierKey | null) => void;
onTerminalReady?: (
sendData: (data: string) => void,
connectionStatus:
| "connecting"
| "connected"
| "disconnected"
| "error"
| "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => void;
}
export interface TerminalRef {
fit: () => void;
focus: () => void;
reset: () => void;
}
const FONT_SIZE_KEY = "terminal-font-size";
const MIN_FONT_SIZE = 4;
const MAX_FONT_SIZE = 24;
const RECONNECT_ATTEMPTS = 3;
const RECONNECT_DELAY_BASE = 1000;
export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
(
{
instanceId,
sessionId,
onClose,
isMobile = false,
showControls = true,
activeModifier,
onModifierChange,
onTerminalReady,
},
ref,
) => {
const terminalRef = useRef<HTMLDivElement>(null);
const hiddenInputRef = useRef<HTMLInputElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const reconnectAttemptsRef = useRef(0);
const onTerminalReadyRef = useRef(onTerminalReady);
onTerminalReadyRef.current = onTerminalReady;
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
const [status, setStatus] = useState<
"connecting" | "connected" | "disconnected" | "error" | "resetting"
>("connecting");
const [error, setError] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => {
if (typeof window === "undefined") return isMobile ? 8 : 8;
const stored = localStorage.getItem(FONT_SIZE_KEY);
if (stored) {
const parsed = parseInt(stored, 10);
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, parsed));
}
return isMobile ? 8 : 8;
});
const lastPingRef = useRef<number>(0);
const heartbeatCheckRef = useRef<number | null>(null);
const isUnmountingRef = useRef(false);
const permanentErrorRef = useRef<string | null>(null);
const calculateFontSize = useCallback(() => {
return fontSize;
}, [fontSize]);
const connectWebSocket = useCallback(() => {
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const wsPath = sessionId
? `/ws/tool-instances/${instanceId}/terminal/${sessionId}`
: `/ws/tool-instances/${instanceId}/terminal`;
const wsUrl = `${wsProtocol}//${wsHost}${wsPath}`;
// WebSocket connection established
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onopen = () => {
setStatus("connected");
setError(null);
reconnectAttemptsRef.current = 0;
lastPingRef.current = Date.now();
// Send current terminal size immediately on connect
if (termRef.current) {
const { cols, rows } = termRef.current;
// Only send if we have valid dimensions
if (cols > 0 && rows > 0) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
// Start heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
}
heartbeatCheckRef.current = window.setInterval(() => {
const elapsed = Date.now() - lastPingRef.current;
if (elapsed > 60000) {
// No ping for 60 seconds, connection may be dead
ws.close(4000, "Heartbeat timeout");
}
}, 30000);
};
// Flow control: accumulate processed bytes and send ack
let ackAccumulator = 0;
const ACK_THRESHOLD = 4096;
let ackTimeout: ReturnType<typeof setTimeout> | null = null;
const flushAck = () => {
if (ackAccumulator > 0 && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator }));
ackAccumulator = 0;
}
};
ws.onmessage = (event) => {
if (!termRef.current) return;
if (event.data instanceof ArrayBuffer) {
const data = new Uint8Array(event.data);
termRef.current.write(data);
// Flow control: accumulate processed bytes
ackAccumulator += data.length;
if (ackAccumulator >= ACK_THRESHOLD) {
flushAck();
} else if (!ackTimeout) {
ackTimeout = setTimeout(() => {
ackTimeout = null;
flushAck();
}, 100);
}
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status") {
if (msg.status === "connected") {
setStatus("connected");
setError(null);
// Clear terminal and refit after reset/reconnect
if (termRef.current) {
termRef.current.clear();
requestAnimationFrame(() => {
if (fitAddonRef.current && termRef.current) {
fitAddonRef.current.fit();
const { cols, rows } = termRef.current;
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(
JSON.stringify({ type: "resize", cols, rows }),
);
}
}
});
}
} else if (msg.status === "resetting") {
setStatus("resetting");
}
} else if (msg.type === "ping") {
// Respond with pong and update last ping time
lastPingRef.current = Date.now();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" }));
}
}
} catch {
termRef.current?.write(event.data);
}
}
};
ws.onclose = (event) => {
// Clean up heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null;
}
// Permanent errors: do not retry
if (event.code === 4001 || event.code === 4003 || event.code === 4004) {
const reason = event.reason || `Instance error (code: ${event.code})`;
setStatus("error");
setError(reason);
permanentErrorRef.current = reason;
return;
}
if (event.code === 1000) {
setStatus("disconnected");
return;
}
if (event.code === 4000) {
// Server closed old connection for concurrent connection - don't reconnect
// The new connection is already established
return;
}
// Transient errors: attempt reconnection
setStatus("disconnected");
setError(`Connection closed (code: ${event.code})`);
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
reconnectAttemptsRef.current++;
const delay =
RECONNECT_DELAY_BASE *
Math.pow(2, reconnectAttemptsRef.current - 1);
setTimeout(() => {
if (isUnmountingRef.current) {
return;
}
if (document.visibilityState !== "hidden") {
connectWebSocket();
}
}, delay);
}
};
ws.onerror = () => {
setStatus("error");
setError("WebSocket error");
};
return ws;
}, [instanceId, sessionId]);
useEffect(() => {
if (!terminalRef.current) return;
// Initialize terminal
const currentFontSize = calculateFontSize();
const term = new Terminal({
cursorBlink: true,
fontSize: currentFontSize,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
lineHeight: 1.2,
letterSpacing: 0,
allowTransparency: false,
scrollback: 10000,
ignoreBracketedPasteMode: false,
fastScrollSensitivity: 5,
scrollSensitivity: 1,
smoothScrollDuration: 0,
theme: {
background: "#1e1e1e",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
selectionBackground: "#264f78",
black: "#000000",
red: "#cd3131",
green: "#0dbc79",
yellow: "#e5e510",
blue: "#2472c8",
magenta: "#bc3fbc",
cyan: "#11a8cd",
white: "#e5e5e5",
brightBlack: "#666666",
brightRed: "#f14c4c",
brightGreen: "#23d18b",
brightYellow: "#f5f543",
brightBlue: "#3b8eea",
brightMagenta: "#d670d6",
brightCyan: "#29b8db",
brightWhite: "#e5e5e5",
},
});
termRef.current = term;
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
// Load WebGL renderer for GPU acceleration, fall back to DOM
let webglAddon: WebglAddon | null = null;
try {
webglAddon = new WebglAddon();
term.loadAddon(webglAddon);
webglAddon.onContextLoss(() => {
console.warn("WebGL context lost, falling back to DOM renderer");
try {
webglAddon?.dispose();
} catch {
// ignore
}
webglAddon = null;
// Trigger a refit since cell dimensions may differ
requestAnimationFrame(() => fitTerminal());
});
} catch (e) {
console.warn("WebGL renderer failed to load, using DOM renderer", e);
}
const container = terminalRef.current;
// Define fitTerminal before connectWebSocket so it's available in onmessage
let lastSentCols = 0;
let lastSentRows = 0;
const fitTerminal = () => {
if (!fitAddonRef.current || !termRef.current) return;
try {
fitAddonRef.current.fit();
} catch {
// Ignore fit errors during initialization
return;
}
const { cols, rows } = termRef.current;
// Only send resize when dimensions actually changed
if (
cols > 0 &&
rows > 0 &&
(cols !== lastSentCols || rows !== lastSentRows)
) {
lastSentCols = cols;
lastSentRows = rows;
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
};
// Open xterm first (must happen before fit)
term.open(container);
term.focus();
const ws = connectWebSocket();
// Mobile touch scroll.
// In normal mode xterm.js has a scrollable viewport; in alternate
// screen (tmux/vim) there is no scrollback and the only way to
// scroll is to send mouse-wheel protocol sequences to the
// application. We detect which situation we're in by checking
// whether the viewport has scrollable height.
let touchCleanup: (() => void) | undefined;
if (isMobile) {
let startY = 0;
let startX = 0;
let isScrolling = false;
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
startY = e.touches[0].clientY;
startX = e.touches[0].clientX;
isScrolling = false;
}
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const deltaY = startY - touch.clientY;
const deltaX = Math.abs(startX - touch.clientX);
if (!isScrolling) {
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 4) {
isScrolling = true;
}
}
if (isScrolling) {
e.preventDefault();
const viewport = container.querySelector(
".xterm-viewport",
) as HTMLElement | null;
if (!viewport) return;
// If the viewport is scrollable, scroll it directly.
// Otherwise we are in alternate screen (tmux/vim) and must
// send SGR 1006 mouse-wheel protocol data.
const hasScrollback = viewport.scrollHeight > viewport.clientHeight;
if (hasScrollback) {
viewport.scrollTop += deltaY;
} else {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN && termRef.current) {
// Use the cursor position as the wheel location so
// tmux knows which pane to scroll.
const buf = termRef.current.buffer.active;
const col = buf.cursorX + 1;
const row = buf.cursorY + 1;
// SGR 1006: 64 = wheel-up, 65 = wheel-down
const btn = deltaY > 0 ? 64 : 65;
ws.send(`\x1b[<${btn};${col};${row}M`);
}
}
startY = touch.clientY;
}
};
const onTouchEnd = () => {
isScrolling = false;
};
container.addEventListener("touchstart", onTouchStart, {
passive: true,
capture: true,
});
container.addEventListener("touchmove", onTouchMove, {
passive: false,
capture: true,
});
container.addEventListener("touchend", onTouchEnd, {
capture: true,
});
touchCleanup = () => {
container.removeEventListener("touchstart", onTouchStart, {
capture: true,
});
container.removeEventListener("touchmove", onTouchMove, {
capture: true,
});
container.removeEventListener("touchend", onTouchEnd, {
capture: true,
});
};
}
// Initial fit after layout settles (terminal must be opened first)
let fitAttempts = 0;
const doInitialFit = () => {
if (!container.isConnected) return;
fitAttempts++;
// Ensure container has dimensions before fitting
if (container.clientWidth > 0 && container.clientHeight > 0) {
fitTerminal();
} else if (fitAttempts < 50) {
// Container not ready yet, try again (max 50 attempts ~ 1s)
requestAnimationFrame(doInitialFit);
}
};
requestAnimationFrame(doInitialFit);
// Refit after font load (metrics may change)
document.fonts.ready.then(() => {
requestAnimationFrame(() => fitTerminal());
});
// Handle terminal input
term.onData((data) => {
const currentWs = wsRef.current;
if (currentWs?.readyState !== WebSocket.OPEN) return;
// Apply active modifier to single-character input
const modifier = activeModifierRef.current;
if (modifier && data.length === 1) {
const modified = applyModifierToChar(data, modifier);
if (modified) {
currentWs.send(modified);
onModifierChange?.(null);
return;
}
}
currentWs.send(data);
});
// Handle container resize with ResizeObserver for accurate dimension tracking
let resizeTimeout: ReturnType<typeof setTimeout>;
let lastWidth = 0;
let lastHeight = 0;
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
// Only trigger if dimensions actually changed
if (width === lastWidth && height === lastHeight) return;
lastWidth = width;
lastHeight = height;
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
requestAnimationFrame(() => {
if (!container.isConnected) return;
fitTerminal();
});
}, 50);
});
resizeObserver.observe(container);
// Window resize fallback (for viewport changes that don't affect container dimensions)
let windowResizeTimeout: ReturnType<typeof setTimeout>;
const handleWindowResize = () => {
clearTimeout(windowResizeTimeout);
windowResizeTimeout = setTimeout(() => {
requestAnimationFrame(() => fitTerminal());
}, 250);
};
window.addEventListener("resize", handleWindowResize);
// Refit after mobile header auto-hides (3s delay + 0.3s transition)
const headerHideTimeout = setTimeout(() => {
fitTerminal();
}, 4000);
// Notify parent about terminal readiness
if (onTerminalReadyRef.current) {
const sendData = (data: string) => {
const currentWs = wsRef.current;
if (currentWs?.readyState === WebSocket.OPEN) {
currentWs.send(data);
}
};
const focusInput = () => {
termRef.current?.focus();
};
const changeFontSize = (delta: number) => {
handleFontSizeChangeRef.current(delta);
};
onTerminalReadyRef.current(
sendData,
status,
focusInput,
changeFontSize,
);
}
// Visibility API for reconnection
const handleVisibilityChange = () => {
if (
document.visibilityState === "visible" &&
ws &&
ws.readyState !== WebSocket.OPEN
) {
if (permanentErrorRef.current) {
return;
}
reconnectAttemptsRef.current = 0;
connectWebSocket();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
isUnmountingRef.current = true;
clearTimeout(resizeTimeout);
clearTimeout(windowResizeTimeout);
clearTimeout(headerHideTimeout);
resizeObserver.disconnect();
window.removeEventListener("resize", handleWindowResize);
document.removeEventListener(
"visibilitychange",
handleVisibilityChange,
);
if (touchCleanup) touchCleanup();
if (ws) {
ws.close(1000, "Component unmounting");
}
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
heartbeatCheckRef.current = null;
}
// Dispose WebGL addon BEFORE the terminal to avoid race with
// RenderService.setRenderer accessing a disposed renderer
if (webglAddon) {
try {
webglAddon.dispose();
} catch {
// Ignore disposal errors from partially torn-down terminal
}
webglAddon = null;
}
try {
term.dispose();
} catch {
// Ignore disposal errors from partially torn-down terminal
}
};
}, [instanceId, connectWebSocket]);
useImperativeHandle(ref, () => ({
fit: () => {
if (fitAddonRef.current && termRef.current) {
try {
fitAddonRef.current.fit();
const { cols, rows } = termRef.current;
if (
wsRef.current?.readyState === WebSocket.OPEN &&
cols > 0 &&
rows > 0
) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows }),
);
}
} catch {
// Ignore fit errors
}
}
},
focus: () => {
termRef.current?.focus();
},
reset: () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "reset" }));
}
},
}));
// Update parent about status changes
useEffect(() => {
if (onTerminalReady && termRef.current) {
const sendData = (data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(data);
}
};
const focusInput = () => {
termRef.current?.focus();
};
const changeFontSize = (delta: number) => {
handleFontSizeChangeRef.current(delta);
};
onTerminalReady(sendData, status, focusInput, changeFontSize);
}
}, [status, onTerminalReady]);
const handleFontSizeChange = (delta: number) => {
const newSize = Math.max(
MIN_FONT_SIZE,
Math.min(MAX_FONT_SIZE, fontSize + delta),
);
setFontSize(newSize);
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
if (termRef.current && fitAddonRef.current) {
termRef.current.options.fontSize = newSize;
requestAnimationFrame(() => {
if (termRef.current && fitAddonRef.current) {
try {
fitAddonRef.current.fit();
const { cols, rows } = termRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: "resize",
cols,
rows,
}),
);
}
} catch {
// Ignore fit errors during re-initialization
}
}
});
}
};
handleFontSizeChangeRef.current = handleFontSizeChange;
const handleCopy = async () => {
if (!termRef.current) return;
const selection = termRef.current.getSelection();
if (selection) {
try {
await navigator.clipboard.writeText(selection);
} catch {
// Fallback for older browsers
const textarea = document.createElement("textarea");
textarea.value = selection;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
}
};
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText();
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(text);
}
} catch {
// Clipboard API not available
}
};
// Focus terminal on mobile to keep keyboard open
const handleTerminalClick = () => {
if (isMobile && termRef.current) {
termRef.current.focus();
}
};
return (
<div
className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}
>
{showControls && (
<div className="terminal-header">
<div className="terminal-header-left">
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">
{status === "resetting"
? "Resetting..."
: reconnectAttemptsRef.current > 0 && status !== "connected"
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
: status}
</span>
</div>
{isMobile && (
<>
<button
className="terminal-header-button"
onClick={handleCopy}
type="button"
aria-label="Copy selection"
>
Copy
</button>
<button
className="terminal-header-button"
onClick={handlePaste}
type="button"
aria-label="Paste from clipboard"
>
Paste
</button>
</>
)}
</div>
<div className="terminal-header-right">
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
</button>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
<button
className="terminal-header-button"
onClick={() => setShowResetConfirm(true)}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
{onClose && (
<button
className="terminal-close"
onClick={onClose}
type="button"
>
Close
</button>
)}
</div>
</div>
)}
{showResetConfirm && (
<div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
<p>
Reset terminal? This will kill the current shell session and
start fresh.
</p>
<div className="terminal-reset-confirm-buttons">
<button
className="terminal-reset-confirm-button cancel"
onClick={() => setShowResetConfirm(false)}
type="button"
>
Cancel
</button>
<button
className="terminal-reset-confirm-button confirm"
onClick={() => {
setShowResetConfirm(false);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "reset" }));
}
}}
type="button"
>
Reset
</button>
</div>
</div>
</div>
)}
{error && (
<div className="terminal-error">
{error}
{status === "error" && (
<button
className="terminal-reconnect"
onClick={() => {
reconnectAttemptsRef.current = 0;
connectWebSocket();
}}
type="button"
>
Reconnect
</button>
)}
</div>
)}
<div
ref={terminalRef}
className="terminal-container"
onClick={handleTerminalClick}
/>
{isMobile && (
<input
ref={hiddenInputRef}
type="text"
className="terminal-hidden-input"
aria-hidden="true"
/>
)}
</div>
);
},
);
TerminalComponent.displayName = "TerminalComponent";