import React, { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { TerminalComponent, type TerminalRef } from "../components/terminal"; import { TerminalSessionTabs, type TerminalSessionInfo, } from "../components/terminal-session-tabs"; import { Icon } from "../components/icon"; import { SpecialKeysStrip } from "../components/special-keys-strip"; import { SpecialKeysPanel } from "../components/special-keys-panel"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { useAutoHide } from "../hooks/use-auto-hide"; import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard"; import { useTerminalSessions } from "../hooks/use-terminal-sessions"; import type { TerminalSession } from "../api/terminal"; import type { ModifierKey } from "../hooks/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"], })); type TerminalStatus = | "connecting" | "connected" | "disconnected" | "error" | "resetting"; export const TerminalPage: React.FC = () => { 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 }); // Track terminal status and callbacks for unified fullscreen header 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 { sessions, activeSessionId, setActiveSessionId, createSession, closeSession, renameSession, resetSession, loading, error, } = useTerminalSessions(instanceId ?? ""); // Auto-create default session if none exist after loading completes useEffect(() => { if (!loading && sessions.length === 0 && !error && instanceId) { void createSession("Session 1"); } }, [loading, sessions.length, error, instanceId, createSession]); // Ensure refs map is kept in sync with sessions useEffect(() => { for (const session of sessions) { if (!terminalRefs.current[session.id]) { terminalRefs.current[session.id] = React.createRef(); } } // Clean up refs for closed sessions 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]); // Fit and focus active terminal when switching tabs useEffect(() => { if (activeSessionId && terminalRefs.current[activeSessionId]) { const ref = terminalRefs.current[activeSessionId]; // Double rAF ensures layout has settled after the display:block switch 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; 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; default: break; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ sessions, activeSessionId, createSession, closeSession, resetSession, setActiveSessionId, ]); // Keep screen awake while terminal is open useEffect(() => { let wakeLock: WakeLockSentinel | null = null; const requestWakeLock = async () => { try { if ("wakeLock" in navigator) { wakeLock = await navigator.wakeLock.request("screen"); } } catch { // Wake lock may be denied; silently ignore } }; void requestWakeLock(); const handleVisibilityChange = () => { if (document.visibilityState === "visible") { void requestWakeLock(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); wakeLock?.release().catch(() => {}); }; }, []); // Click outside terminal content/header to exit fullscreen 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 handleClose = useCallback( async (sessionId: string) => { await closeSession(sessionId); }, [closeSession], ); 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 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 handleFontSizeChange = useCallback((delta: number) => { changeFontSizeRef.current?.(delta); }, []); const handleSendKey = useCallback((data: string) => { sendDataRef.current?.(data); }, []); const handleReset = useCallback(() => { if (activeSessionId && terminalRefs.current[activeSessionId]) { terminalRefs.current[activeSessionId].current?.reset(); } }, [activeSessionId]); if (!instanceId) { return (

Terminal

No instance ID provided.

); } const sessionInfos = SESSIONS_TO_INFO(sessions); if (isMobile) { const activeSession = sessions.find((s) => s.id === activeSessionId); const status = terminalStatuses[activeSessionId ?? "default"] ?? "connecting"; return (
{/* Overlay status bar — floats over terminal, never resizes it */}
e.stopPropagation()} >
{activeSession?.name || "Terminal"}
{/* Terminal content — always fills full viewport */}
headerAutoHide.toggle()} > {error &&
{error}
} {sessions .filter((session) => session.id === activeSessionId) .map((session) => (
handleClose(session.id)} isMobile={true} showControls={false} activeModifier={activeModifier} onModifierChange={setActiveModifier} onTerminalReady={handleTerminalReady} />
))} {sessions.length === 0 && !loading && (

No terminal sessions. Press Alt+Shift+N to create one.

)}
setShowSpecialKeysPanel(true)} onKeepFocus={() => focusInputRef.current?.()} activeModifier={activeModifier} onModifierChange={setActiveModifier} /> setShowSpecialKeysPanel(false)} onKeepFocus={() => focusInputRef.current?.()} activeModifier={activeModifier} onModifierChange={setActiveModifier} />
); } return (
{!isFullscreen && (

Terminal

)} {isFullscreen ? (
{showResetConfirm && (

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

)}
) : ( )}
{error &&
{error}
} {sessions .filter((session) => session.id === activeSessionId) .map((session) => (
handleClose(session.id)} isMobile={false} showControls={!isFullscreen} onTerminalReady={handleTerminalReady} />
))} {sessions.length === 0 && !loading && (

No terminal sessions. Press Alt+Shift+N to create one.

)}
); };