diff --git a/apps/web/src/components/features/mobile/mobile-terminal-wrapper.tsx b/apps/web/src/components/features/mobile/mobile-terminal-wrapper.tsx index 12697eb..d5b546f 100644 --- a/apps/web/src/components/features/mobile/mobile-terminal-wrapper.tsx +++ b/apps/web/src/components/features/mobile/mobile-terminal-wrapper.tsx @@ -27,10 +27,17 @@ export const MobileTerminalWrapper: React.FC = ({ const { isOpen: isKeyboardOpen, height: keyboardHeight } = useVirtualKeyboard(); const [showPanel, setShowPanel] = useState(false); - const [activeModifier, setActiveModifier] = useState(null); + const [activeModifier, setActiveModifier] = useState( + null, + ); const [terminalRef, setTerminalRef] = useState<{ sendData: (data: string) => void; - connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting"; + connectionStatus: + | "connecting" + | "connected" + | "disconnected" + | "error" + | "resetting"; focusInput: () => void; changeFontSize: (delta: number) => void; } | null>(null); @@ -42,17 +49,33 @@ export const MobileTerminalWrapper: React.FC = ({ }, [headerAutoHide]); const handleTerminalReady = useCallback( - (sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => { - setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize }); + ( + _sessionId: string | undefined, + sendData: (data: string) => void, + connectionStatus: + | "connecting" + | "connected" + | "disconnected" + | "error" + | "resetting", + focusInput: () => void, + changeFontSize: (delta: number) => void, + ) => { + setTerminalRef({ + sendData, + connectionStatus, + focusInput, + changeFontSize, + }); }, - [] + [], ); const handleSendKey = useCallback( (data: string) => { terminalRef?.sendData(data); }, - [terminalRef] + [terminalRef], ); if (!isMobile) { diff --git a/apps/web/src/components/features/terminal/DesktopTerminalView.tsx b/apps/web/src/components/features/terminal/DesktopTerminalView.tsx index 06c9ffc..75f56b1 100644 --- a/apps/web/src/components/features/terminal/DesktopTerminalView.tsx +++ b/apps/web/src/components/features/terminal/DesktopTerminalView.tsx @@ -1,6 +1,9 @@ import React from "react"; import { TerminalComponent, type TerminalRef } from "./terminal"; -import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs"; +import { + TerminalSessionTabs, + type TerminalSessionInfo, +} from "./terminal-session-tabs"; import type { TerminalSession } from "../../../api/terminal"; interface Props { @@ -8,7 +11,9 @@ interface Props { sessions: TerminalSession[]; sessionInfos: TerminalSessionInfo[]; activeSessionId: string; - terminalRefs: React.MutableRefObject>>; + terminalRefs: React.MutableRefObject< + Record> + >; isFullscreen: boolean; status: string; error: string | null; @@ -26,6 +31,7 @@ interface Props { onHideResetConfirm: () => void; onReset: () => void; onTerminalReady: ( + sessionId: string | undefined, sendData: (data: string) => void, status: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, @@ -64,7 +70,11 @@ export const DesktopTerminalView: React.FC = ({ > {!isFullscreen && (
-

Terminal

diff --git a/apps/web/src/components/features/terminal/MobileTerminalView.tsx b/apps/web/src/components/features/terminal/MobileTerminalView.tsx index f373827..1f993d4 100644 --- a/apps/web/src/components/features/terminal/MobileTerminalView.tsx +++ b/apps/web/src/components/features/terminal/MobileTerminalView.tsx @@ -1,6 +1,9 @@ import React from "react"; import { TerminalComponent, type TerminalRef } from "./terminal"; -import { TerminalSessionTabs, type TerminalSessionInfo } from "./terminal-session-tabs"; +import { + TerminalSessionTabs, + type TerminalSessionInfo, +} from "./terminal-session-tabs"; import { Icon } from "../../icon"; import { SpecialKeysStrip } from "./special-keys-strip"; import { SpecialKeysPanel } from "./special-keys-panel"; @@ -12,7 +15,9 @@ interface Props { sessions: TerminalSession[]; sessionInfos: TerminalSessionInfo[]; activeSessionId: string; - terminalRefs: React.MutableRefObject>>; + terminalRefs: React.MutableRefObject< + Record> + >; status: string; error: string | null; loading: boolean; @@ -29,6 +34,7 @@ interface Props { onCreate: () => void; onRename: (id: string, name: string) => void; onTerminalReady: ( + sessionId: string | undefined, sendData: (data: string) => void, status: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, @@ -73,25 +79,53 @@ export const MobileTerminalView: React.FC = ({ return (
-
e.stopPropagation()}> +
e.stopPropagation()} + >
-
- {activeSession?.name || "Terminal"} - + + {activeSession?.name || "Terminal"} + +
- - -
diff --git a/apps/web/src/components/features/terminal/terminal.tsx b/apps/web/src/components/features/terminal/terminal.tsx index f596136..077664a 100644 --- a/apps/web/src/components/features/terminal/terminal.tsx +++ b/apps/web/src/components/features/terminal/terminal.tsx @@ -1,9 +1,9 @@ import React, { - useEffect, - useImperativeHandle, - useRef, - useState, - useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, + useCallback, } from "react"; import { Terminal } from "xterm"; import { FitAddon } from "xterm-addon-fit"; @@ -11,35 +11,36 @@ import { WebLinksAddon } from "xterm-addon-web-links"; import "xterm/css/xterm.css"; import { - applyModifierToChar, - type ModifierKey, + 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; + instanceId: string; + sessionId?: string; + onClose?: () => void; + isMobile?: boolean; + showControls?: boolean; + activeModifier?: ModifierKey | null; + onModifierChange?: (modifier: ModifierKey | null) => void; + onTerminalReady?: ( + sessionId: string | undefined, + 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; + fit: () => void; + focus: () => void; + reset: () => void; } const FONT_SIZE_KEY = "terminal-font-size"; @@ -49,828 +50,835 @@ const RECONNECT_ATTEMPTS = 3; const RECONNECT_DELAY_BASE = 1000; export const TerminalComponent = React.forwardRef( - ( - { - instanceId, - sessionId, - onClose, - isMobile = false, - showControls = true, - activeModifier, - onModifierChange, - onTerminalReady, - }, - ref, - ) => { - const terminalRef = useRef(null); - const hiddenInputRef = useRef(null); - const wsRef = useRef(null); - const termRef = useRef(null); - const fitAddonRef = useRef(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(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(0); - const heartbeatCheckRef = useRef(null); - const isUnmountingRef = useRef(false); - const permanentErrorRef = useRef(null); + ( + { + instanceId, + sessionId, + onClose, + isMobile = false, + showControls = true, + activeModifier, + onModifierChange, + onTerminalReady, + }, + ref, + ) => { + const terminalRef = useRef(null); + const hiddenInputRef = useRef(null); + const wsRef = useRef(null); + const termRef = useRef(null); + const fitAddonRef = useRef(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(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(0); + const heartbeatCheckRef = useRef(null); + const isUnmountingRef = useRef(false); + const permanentErrorRef = useRef(null); - const calculateFontSize = useCallback(() => { - return fontSize; - }, [fontSize]); + 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}`; + 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; + // 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(); + 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 })); - } - } + // 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); - }; + // 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 | null = null; + // Flow control: accumulate processed bytes and send ack + let ackAccumulator = 0; + const ACK_THRESHOLD = 4096; + let ackTimeout: ReturnType | null = null; - const flushAck = () => { - if (ackAccumulator > 0 && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator })); - ackAccumulator = 0; - } - }; + 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; + ws.onmessage = (event) => { + if (!termRef.current) return; - if (event.data instanceof ArrayBuffer) { - const data = new Uint8Array(event.data); - termRef.current.write(data); + 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); - // Refit after reset/reconnect but do NOT clear the - // terminal: the server keeps the session buffer, and - // clearing would erase it when the user switches back - // to an already-connected session. - if (termRef.current) { - 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); - } - } - }; + // 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); + // Refit after reset/reconnect but do NOT clear the + // terminal: the server keeps the session buffer, and + // clearing would erase it when the user switches back + // to an already-connected session. + if (termRef.current) { + 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; - } + 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; - } + // 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 === 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; - } + 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})`); + // 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); - } - }; + 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"); - }; + ws.onerror = () => { + setStatus("error"); + setError("WebSocket error"); + }; - return ws; - }, [instanceId, sessionId]); + return ws; + }, [instanceId, sessionId]); - useEffect(() => { - if (!terminalRef.current) return; + 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", - }, - }); + // 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; + termRef.current = term; - const fitAddon = new FitAddon(); - fitAddonRef.current = fitAddon; - term.loadAddon(fitAddon); - term.loadAddon(new WebLinksAddon()); + const fitAddon = new FitAddon(); + fitAddonRef.current = fitAddon; + term.loadAddon(fitAddon); + term.loadAddon(new WebLinksAddon()); - // NOTE: WebGL renderer disabled. - // The WebGL addon causes black-on-black rendering artifacts with - // tmux/vim reverse-video (inverse color) sequences on desktop. - // Mobile already uses the DOM renderer (WebGL fails there), which - // handles these color attributes correctly. The DOM renderer is - // fast enough for typical terminal workloads. - // See: xterm.js WebGL known issues with reverse video / minimumContrastRatio + // NOTE: WebGL renderer disabled. + // The WebGL addon causes black-on-black rendering artifacts with + // tmux/vim reverse-video (inverse color) sequences on desktop. + // Mobile already uses the DOM renderer (WebGL fails there), which + // handles these color attributes correctly. The DOM renderer is + // fast enough for typical terminal workloads. + // See: xterm.js WebGL known issues with reverse video / minimumContrastRatio - const container = terminalRef.current; + 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 })); - } - } - }; + // 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(); + // 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 the active buffer by reference equality - // (term.buffer.active === term.buffer.alternate) because the - // `type` string can be unreliable in some xterm.js versions. - let touchCleanup: (() => void) | undefined; - if (isMobile) { - let startY = 0; - let startX = 0; - let isScrolling = false; - let scrollPending = 0; - const WHEEL_DISTANCE = 12; + // 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 the active buffer by reference equality + // (term.buffer.active === term.buffer.alternate) because the + // `type` string can be unreliable in some xterm.js versions. + let touchCleanup: (() => void) | undefined; + if (isMobile) { + let startY = 0; + let startX = 0; + let isScrolling = false; + let scrollPending = 0; + const WHEEL_DISTANCE = 12; - const flushScroll = (force = false) => { - if (scrollPending === 0) return; - if (!force && Math.abs(scrollPending) < WHEEL_DISTANCE) return; - const direction = Math.sign(scrollPending); - const steps = Math.max( - 1, - Math.floor(Math.abs(scrollPending) / WHEEL_DISTANCE), - ); - const ws = wsRef.current; - if (ws?.readyState === WebSocket.OPEN && termRef.current) { - const buf = termRef.current.buffer.active; - const col = buf.cursorX + 1; - const row = buf.cursorY + 1; - const btn = direction > 0 ? 64 : 65; - for (let i = 0; i < steps; i++) { - ws.send(`\x1b[<${btn};${col};${row}M`); - } - } - scrollPending = 0; - }; + const flushScroll = (force = false) => { + if (scrollPending === 0) return; + if (!force && Math.abs(scrollPending) < WHEEL_DISTANCE) return; + const direction = Math.sign(scrollPending); + const steps = Math.max( + 1, + Math.floor(Math.abs(scrollPending) / WHEEL_DISTANCE), + ); + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN && termRef.current) { + const buf = termRef.current.buffer.active; + const col = buf.cursorX + 1; + const row = buf.cursorY + 1; + const btn = direction > 0 ? 64 : 65; + for (let i = 0; i < steps; i++) { + ws.send(`\x1b[<${btn};${col};${row}M`); + } + } + scrollPending = 0; + }; - const isAlternateScreen = () => { - const term = termRef.current; - if (!term) return false; - return term.buffer.active === term.buffer.alternate; - }; + const isAlternateScreen = () => { + const term = termRef.current; + if (!term) return false; + return term.buffer.active === term.buffer.alternate; + }; - const onTouchStart = (e: TouchEvent) => { - if (e.touches.length === 1) { - startY = e.touches[0].clientY; - startX = e.touches[0].clientX; - isScrolling = false; - scrollPending = 0; - } - }; - 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); + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length === 1) { + startY = e.touches[0].clientY; + startX = e.touches[0].clientX; + isScrolling = false; + scrollPending = 0; + } + }; + 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); - // Decide early whether this is a vertical scroll gesture. - if (!isScrolling) { - if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 2) { - isScrolling = true; - } - } - if (!isScrolling) return; + // Decide early whether this is a vertical scroll gesture. + if (!isScrolling) { + if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 2) { + isScrolling = true; + } + } + if (!isScrolling) return; - // Take over the gesture so the page/toolbar never scrolls. - e.preventDefault(); + // Take over the gesture so the page/toolbar never scrolls. + e.preventDefault(); - if (isAlternateScreen()) { - // Alternate screen (tmux/vim): accumulate the swipe and - // send SGR 1006 mouse-wheel events in steps. - scrollPending += deltaY; - flushScroll(); - } else if (termRef.current) { - // Normal buffer: let xterm.js scroll its own viewport by - // the number of lines corresponding to the swipe distance. - const lineHeight = - termRef.current.options.fontSize != null - ? (termRef.current.options.fontSize as number) * 1.2 - : 10; - const lines = Math.round(deltaY / lineHeight); - if (lines !== 0) { - termRef.current.scrollLines(lines); - } - } - startY = touch.clientY; - }; - const onTouchEnd = () => { - if (isScrolling) { - flushScroll(true); - } - isScrolling = false; - }; + if (isAlternateScreen()) { + // Alternate screen (tmux/vim): accumulate the swipe and + // send SGR 1006 mouse-wheel events in steps. + scrollPending += deltaY; + flushScroll(); + } else if (termRef.current) { + // Normal buffer: let xterm.js scroll its own viewport by + // the number of lines corresponding to the swipe distance. + const lineHeight = + termRef.current.options.fontSize != null + ? (termRef.current.options.fontSize as number) * 1.2 + : 10; + const lines = Math.round(deltaY / lineHeight); + if (lines !== 0) { + termRef.current.scrollLines(lines); + } + } + startY = touch.clientY; + }; + const onTouchEnd = () => { + if (isScrolling) { + flushScroll(true); + } + isScrolling = false; + }; - container.addEventListener("touchstart", onTouchStart, { - passive: false, - 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, - }); - }; - } + container.addEventListener("touchstart", onTouchStart, { + passive: false, + 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); + // 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()); - }); + // 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; + // 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; - } - } + // 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); - }); + currentWs.send(data); + }); - // Handle container resize with ResizeObserver for accurate dimension tracking - let resizeTimeout: ReturnType; - let lastWidth = 0; - let lastHeight = 0; - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; + // Handle container resize with ResizeObserver for accurate dimension tracking + let resizeTimeout: ReturnType; + 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; + 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); + 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; - const handleWindowResize = () => { - clearTimeout(windowResizeTimeout); - windowResizeTimeout = setTimeout(() => { - requestAnimationFrame(() => fitTerminal()); - }, 250); - }; - window.addEventListener("resize", handleWindowResize); + // Window resize fallback (for viewport changes that don't affect container dimensions) + let windowResizeTimeout: ReturnType; + 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); + // 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, - ); - } + // 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( + sessionId, + 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); + // 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; - } - try { - term.dispose(); - } catch { - // Ignore disposal errors from partially torn-down terminal - } - }; - }, [instanceId, connectWebSocket]); + 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; + } + 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" })); - } - }, - })); + 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]); + // 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( + sessionId, + sendData, + status, + focusInput, + changeFontSize, + ); + } + }, [sessionId, 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 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 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 - } - }; + 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(); - } - }; + // Focus terminal on mobile to keep keyboard open + const handleTerminalClick = () => { + if (isMobile && termRef.current) { + termRef.current.focus(); + } + }; - return ( -
- {showControls && ( -
-
-
- - - {status === "resetting" - ? "Resetting..." - : reconnectAttemptsRef.current > 0 && status !== "connected" - ? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...` - : status} - -
- {isMobile && ( - <> - - - - )} -
-
- - - - {onClose && ( - - )} -
-
- )} - {showResetConfirm && ( -
-
-

- Reset terminal? This will kill the current shell session and - start fresh. -

-
- - -
-
-
- )} - {error && ( -
- {error} - {status === "error" && ( - - )} -
- )} -
- {isMobile && ( - - )} -
- ); - }, + return ( +
+ {showControls && ( +
+
+
+ + + {status === "resetting" + ? "Resetting..." + : reconnectAttemptsRef.current > 0 && status !== "connected" + ? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...` + : status} + +
+ {isMobile && ( + <> + + + + )} +
+
+ + + + {onClose && ( + + )} +
+
+ )} + {showResetConfirm && ( +
+
+

+ Reset terminal? This will kill the current shell session and + start fresh. +

+
+ + +
+
+
+ )} + {error && ( +
+ {error} + {status === "error" && ( + + )} +
+ )} +
+ {isMobile && ( + + )} +
+ ); + }, ); TerminalComponent.displayName = "TerminalComponent"; diff --git a/apps/web/src/hooks/use-terminal-page.ts b/apps/web/src/hooks/use-terminal-page.ts index 482a48b..b916bc8 100644 --- a/apps/web/src/hooks/use-terminal-page.ts +++ b/apps/web/src/hooks/use-terminal-page.ts @@ -10,336 +10,359 @@ import type { TerminalSession } from "../api/terminal"; import type { ModifierKey } from "./use-special-keys"; const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] => - sessions.map((s) => ({ - id: s.id, - name: s.name, - status: s.status as TerminalSessionInfo["status"], - })); + sessions.map((s) => ({ + id: s.id, + name: s.name, + status: s.status as TerminalSessionInfo["status"], + })); type TerminalStatus = - | "connecting" - | "connected" - | "disconnected" - | "error" - | "resetting"; + | "connecting" + | "connected" + | "disconnected" + | "error" + | "resetting"; export const useTerminalPage = () => { - const { instanceId } = useParams<{ instanceId: string }>(); - const navigate = useNavigate(); - const isMobile = useMobileViewport(); - const [isFullscreen, setIsFullscreen] = useState(false); - const terminalRefs = useRef>>({}); - const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile }); + const { instanceId } = useParams<{ instanceId: string }>(); + const navigate = useNavigate(); + const isMobile = useMobileViewport(); + const [isFullscreen, setIsFullscreen] = useState(false); + const terminalRefs = useRef>>({}); + const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile }); - const [terminalStatuses, setTerminalStatuses] = useState< - Record - >({}); - const changeFontSizeRef = useRef<((delta: number) => void) | null>(null); - const sendDataRef = useRef<((data: string) => void) | null>(null); - const focusInputRef = useRef<(() => void) | null>(null); - const [showResetConfirm, setShowResetConfirm] = useState(false); - const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false); - const [activeModifier, setActiveModifier] = useState( - null, - ); - const { isOpen: isKeyboardOpen, height: keyboardHeight } = - useVirtualKeyboard(); + const [terminalStatuses, setTerminalStatuses] = useState< + Record + >({}); + const changeFontSizeRefs = useRef< + Record void) | null> + >({}); + const sendDataRefs = useRef void) | null>>( + {}, + ); + const focusInputRefs = useRef void) | null>>({}); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false); + const [activeModifier, setActiveModifier] = useState( + null, + ); + const { isOpen: isKeyboardOpen, height: keyboardHeight } = + useVirtualKeyboard(); - const [instanceInfo, setInstanceInfo] = useState<{ - display_name: string; - workspace_name?: string | null; - tool_type_name: string; - } | null>(null); + const [instanceInfo, setInstanceInfo] = useState<{ + display_name: string; + workspace_name?: string | null; + tool_type_name: string; + } | null>(null); - const { - sessions, - activeSessionId, - setActiveSessionId, - createSession, - closeSession, - renameSession, - resetSession, - loading, - error, - } = useTerminalSessions(instanceId ?? ""); + const { + sessions, + activeSessionId, + setActiveSessionId, + createSession, + closeSession, + renameSession, + resetSession, + loading, + error, + } = useTerminalSessions(instanceId ?? ""); - // Fetch instance details for tab title - useEffect(() => { - if (!instanceId) return; - const load = async () => { - try { - const { getUserSessions } = await import("../api/sessions"); - const allSessions = await getUserSessions(); - const match = allSessions.find((s) => s.id === instanceId); - if (match) { - setInstanceInfo({ - display_name: match.display_name, - workspace_name: match.workspace_name, - tool_type_name: match.tool_type_name, - }); - } - } catch { - // ignore - } - }; - void load(); - }, [instanceId]); + // Fetch instance details for tab title + useEffect(() => { + if (!instanceId) return; + const load = async () => { + try { + const { getUserSessions } = await import("../api/sessions"); + const allSessions = await getUserSessions(); + const match = allSessions.find((s) => s.id === instanceId); + if (match) { + setInstanceInfo({ + display_name: match.display_name, + workspace_name: match.workspace_name, + tool_type_name: match.tool_type_name, + }); + } + } catch { + // ignore + } + }; + void load(); + }, [instanceId]); - // Auto-create default session - useEffect(() => { - if (!loading && sessions.length === 0 && !error && instanceId) { - void createSession("Session 1"); - } - }, [loading, sessions.length, error, instanceId, createSession]); + // Auto-create default session + useEffect(() => { + if (!loading && sessions.length === 0 && !error && instanceId) { + void createSession("Session 1"); + } + }, [loading, sessions.length, error, instanceId, createSession]); - // Update document title based on active terminal session - useEffect(() => { - if (!instanceId) { - document.title = "Terminal"; - return; - } - const active = sessions.find((s) => s.id === activeSessionId); - const baseName = instanceInfo - ? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}` - : `Instance ${instanceId.slice(0, 8)}`; - if (sessions.length <= 1) { - document.title = baseName; - } else { - const sessionName = active?.name ?? "Session"; - document.title = `${baseName} ${sessionName}`; - } - return () => { - document.title = "Headquarter"; - }; - }, [instanceId, activeSessionId, sessions, instanceInfo]); + // Update document title based on active terminal session + useEffect(() => { + if (!instanceId) { + document.title = "Terminal"; + return; + } + const active = sessions.find((s) => s.id === activeSessionId); + const baseName = instanceInfo + ? `${instanceInfo.workspace_name ?? instanceInfo.display_name} · ${instanceInfo.tool_type_name}` + : `Instance ${instanceId.slice(0, 8)}`; + if (sessions.length <= 1) { + document.title = baseName; + } else { + const sessionName = active?.name ?? "Session"; + document.title = `${baseName} ${sessionName}`; + } + return () => { + document.title = "Headquarter"; + }; + }, [instanceId, activeSessionId, sessions, instanceInfo]); - // Sync refs with sessions - useEffect(() => { - for (const session of sessions) { - if (!terminalRefs.current[session.id]) { - terminalRefs.current[session.id] = React.createRef(); - } - } - const currentIds = new Set(sessions.map((s) => s.id)); - for (const id of Object.keys(terminalRefs.current)) { - if (!currentIds.has(id)) { - delete terminalRefs.current[id]; - } - } - }, [sessions]); + // Sync refs with sessions + useEffect(() => { + for (const session of sessions) { + if (!terminalRefs.current[session.id]) { + terminalRefs.current[session.id] = React.createRef(); + } + } + const currentIds = new Set(sessions.map((s) => s.id)); + for (const id of Object.keys(terminalRefs.current)) { + if (!currentIds.has(id)) { + delete terminalRefs.current[id]; + delete sendDataRefs.current[id]; + delete focusInputRefs.current[id]; + delete changeFontSizeRefs.current[id]; + } + } + setTerminalStatuses((prev) => { + const next: Record = {}; + for (const id of currentIds) { + if (prev[id]) { + next[id] = prev[id]; + } + } + return next; + }); + }, [sessions]); - // Fit and focus active terminal - useEffect(() => { - if (activeSessionId && terminalRefs.current[activeSessionId]) { - const ref = terminalRefs.current[activeSessionId]; - let raf1 = 0; - let raf2 = 0; - raf1 = requestAnimationFrame(() => { - raf2 = requestAnimationFrame(() => { - ref.current?.fit(); - ref.current?.focus(); - }); - }); - return () => { - cancelAnimationFrame(raf1); - cancelAnimationFrame(raf2); - }; - } - }, [activeSessionId]); + // Fit and focus active terminal + useEffect(() => { + if (activeSessionId && terminalRefs.current[activeSessionId]) { + const ref = terminalRefs.current[activeSessionId]; + let raf1 = 0; + let raf2 = 0; + raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => { + ref.current?.fit(); + ref.current?.focus(); + }); + }); + return () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + } + }, [activeSessionId]); - // Keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey; - if (!isAltShift) return; + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey; + if (!isAltShift) return; - switch (e.key.toLowerCase()) { - case "n": - e.preventDefault(); - if (sessions.length < 5) { - void createSession(`Session ${sessions.length + 1}`); - } - break; - case "w": - e.preventDefault(); - if ( - activeSessionId && - window.confirm("Close this terminal session?") - ) { - void closeSession(activeSessionId); - } - break; - case "arrowleft": - e.preventDefault(); - if (activeSessionId) { - const idx = sessions.findIndex((s) => s.id === activeSessionId); - if (idx > 0) setActiveSessionId(sessions[idx - 1].id); - } - break; - case "arrowright": - e.preventDefault(); - if (activeSessionId) { - const idx = sessions.findIndex((s) => s.id === activeSessionId); - if (idx < sessions.length - 1) - setActiveSessionId(sessions[idx + 1].id); - } - break; - case "r": - e.preventDefault(); - if (activeSessionId) void resetSession(activeSessionId); - break; - case "f": - e.preventDefault(); - setIsFullscreen((prev) => !prev); - break; - } - }; + switch (e.key.toLowerCase()) { + case "n": + e.preventDefault(); + if (sessions.length < 5) { + void createSession(`Session ${sessions.length + 1}`); + } + break; + case "w": + e.preventDefault(); + if ( + activeSessionId && + window.confirm("Close this terminal session?") + ) { + void closeSession(activeSessionId); + } + break; + case "arrowleft": + e.preventDefault(); + if (activeSessionId) { + const idx = sessions.findIndex((s) => s.id === activeSessionId); + if (idx > 0) setActiveSessionId(sessions[idx - 1].id); + } + break; + case "arrowright": + e.preventDefault(); + if (activeSessionId) { + const idx = sessions.findIndex((s) => s.id === activeSessionId); + if (idx < sessions.length - 1) + setActiveSessionId(sessions[idx + 1].id); + } + break; + case "r": + e.preventDefault(); + if (activeSessionId) void resetSession(activeSessionId); + break; + case "f": + e.preventDefault(); + setIsFullscreen((prev) => !prev); + break; + } + }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [ - sessions, - activeSessionId, - createSession, - closeSession, - resetSession, - setActiveSessionId, - ]); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + sessions, + activeSessionId, + createSession, + closeSession, + resetSession, + setActiveSessionId, + ]); - // Keep screen awake - useEffect(() => { - let wakeLock: WakeLockSentinel | null = null; - const requestWakeLock = async () => { - try { - if ("wakeLock" in navigator) { - wakeLock = await navigator.wakeLock.request("screen"); - } - } catch { - // ignore - } - }; - void requestWakeLock(); - const handleVisibilityChange = () => { - if (document.visibilityState === "visible") void requestWakeLock(); - }; - document.addEventListener("visibilitychange", handleVisibilityChange); - return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - wakeLock?.release().catch(() => {}); - }; - }, []); + // Keep screen awake + useEffect(() => { + let wakeLock: WakeLockSentinel | null = null; + const requestWakeLock = async () => { + try { + if ("wakeLock" in navigator) { + wakeLock = await navigator.wakeLock.request("screen"); + } + } catch { + // ignore + } + }; + void requestWakeLock(); + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") void requestWakeLock(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + wakeLock?.release().catch(() => {}); + }; + }, []); - // Lock page scroll on mobile - useEffect(() => { - if (!isMobile) return; - document.documentElement.classList.add("terminal-page-open"); - document.body.classList.add("terminal-page-open"); - return () => { - document.documentElement.classList.remove("terminal-page-open"); - document.body.classList.remove("terminal-page-open"); - }; - }, [isMobile]); + // Lock page scroll on mobile + useEffect(() => { + if (!isMobile) return; + document.documentElement.classList.add("terminal-page-open"); + document.body.classList.add("terminal-page-open"); + return () => { + document.documentElement.classList.remove("terminal-page-open"); + document.body.classList.remove("terminal-page-open"); + }; + }, [isMobile]); - const handleFullscreenClick = useCallback( - (e: React.MouseEvent) => { - if (!isFullscreen) return; - const target = e.target as Node; - const current = e.currentTarget as HTMLElement; - const content = current.querySelector(".terminal-page-content"); - const header = current.querySelector(".terminal-fullscreen-header"); - if (content?.contains(target) || header?.contains(target)) return; - setIsFullscreen(false); - }, - [isFullscreen], - ); + const handleFullscreenClick = useCallback( + (e: React.MouseEvent) => { + if (!isFullscreen) return; + const target = e.target as Node; + const current = e.currentTarget as HTMLElement; + const content = current.querySelector(".terminal-page-content"); + const header = current.querySelector(".terminal-fullscreen-header"); + if (content?.contains(target) || header?.contains(target)) return; + setIsFullscreen(false); + }, + [isFullscreen], + ); - const handleSelect = useCallback( - (sessionId: string) => setActiveSessionId(sessionId), - [setActiveSessionId], - ); + const handleSelect = useCallback( + (sessionId: string) => setActiveSessionId(sessionId), + [setActiveSessionId], + ); - const handleClose = useCallback( - async (sessionId: string) => closeSession(sessionId), - [closeSession], - ); + const handleClose = useCallback( + async (sessionId: string) => closeSession(sessionId), + [closeSession], + ); - const handleCreate = useCallback(() => { - void createSession(`Session ${sessions.length + 1}`); - }, [createSession, sessions.length]); + const handleCreate = useCallback(() => { + void createSession(`Session ${sessions.length + 1}`); + }, [createSession, sessions.length]); - const handleRename = useCallback( - (sessionId: string, newName: string) => { - void renameSession(sessionId, newName); - }, - [renameSession], - ); + const handleRename = useCallback( + (sessionId: string, newName: string) => { + void renameSession(sessionId, newName); + }, + [renameSession], + ); - const handleTerminalReady = useCallback( - ( - sendData: (data: string) => void, - status: TerminalStatus, - focusInput: () => void, - changeFontSize: (delta: number) => void, - ) => { - setTerminalStatuses((prev) => ({ - ...prev, - [activeSessionId ?? "default"]: status, - })); - sendDataRef.current = sendData; - focusInputRef.current = focusInput; - changeFontSizeRef.current = changeFontSize; - }, - [activeSessionId], - ); + const handleTerminalReady = useCallback( + ( + sessionId: string | undefined, + sendData: (data: string) => void, + status: TerminalStatus, + focusInput: () => void, + changeFontSize: (delta: number) => void, + ) => { + const key = sessionId ?? "default"; + setTerminalStatuses((prev) => ({ + ...prev, + [key]: status, + })); + sendDataRefs.current[key] = sendData; + focusInputRefs.current[key] = focusInput; + changeFontSizeRefs.current[key] = changeFontSize; + }, + [], + ); - const handleFontSizeChange = useCallback((delta: number) => { - changeFontSizeRef.current?.(delta); - }, []); + const handleFontSizeChange = useCallback( + (delta: number) => { + const key = activeSessionId ?? "default"; + changeFontSizeRefs.current[key]?.(delta); + }, + [activeSessionId], + ); - const handleSendKey = useCallback((data: string) => { - sendDataRef.current?.(data); - }, []); + const handleSendKey = useCallback( + (data: string) => { + const key = activeSessionId ?? "default"; + sendDataRefs.current[key]?.(data); + }, + [activeSessionId], + ); - const handleReset = useCallback(() => { - if (activeSessionId && terminalRefs.current[activeSessionId]) { - terminalRefs.current[activeSessionId].current?.reset(); - } - }, [activeSessionId]); + const handleReset = useCallback(() => { + if (activeSessionId && terminalRefs.current[activeSessionId]) { + terminalRefs.current[activeSessionId].current?.reset(); + } + }, [activeSessionId]); - return { - instanceId, - navigate, - isMobile, - isFullscreen, - setIsFullscreen, - terminalRefs, - headerAutoHide, - terminalStatuses, - sendDataRef, - focusInputRef, - changeFontSizeRef, - showResetConfirm, - setShowResetConfirm, - showSpecialKeysPanel, - setShowSpecialKeysPanel, - activeModifier, - setActiveModifier, - isKeyboardOpen, - keyboardHeight, - sessions, - activeSessionId, - setActiveSessionId, - loading, - error, - handleFullscreenClick, - handleSelect, - handleClose, - handleCreate, - handleRename, - handleTerminalReady, - handleFontSizeChange, - handleSendKey, - handleReset, - sessionInfos: SESSIONS_TO_INFO(sessions), - }; + return { + instanceId, + navigate, + isMobile, + isFullscreen, + setIsFullscreen, + terminalRefs, + headerAutoHide, + terminalStatuses, + showResetConfirm, + setShowResetConfirm, + showSpecialKeysPanel, + setShowSpecialKeysPanel, + activeModifier, + setActiveModifier, + isKeyboardOpen, + keyboardHeight, + sessions, + activeSessionId, + setActiveSessionId, + loading, + error, + handleFullscreenClick, + handleSelect, + handleClose, + handleCreate, + handleRename, + handleTerminalReady, + handleFontSizeChange, + handleSendKey, + handleReset, + sessionInfos: SESSIONS_TO_INFO(sessions), + }; };