diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index 2bf0850..c7442e7 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -56,6 +56,7 @@ export const TerminalComponent: React.FC = ({ }); const lastPingRef = useRef(0); const heartbeatCheckRef = useRef(null); + const containerResizeObserverRef = useRef(null); const calculateFontSize = useCallback(() => { if (!isMobile) return fontSize; @@ -201,17 +202,20 @@ export const TerminalComponent: React.FC = ({ term.loadAddon(fitAddon); term.loadAddon(new WebLinksAddon()); - term.open(terminalRef.current); + // Wait for container to have non-zero dimensions before opening xterm + // xterm docs: "parent must be visible (have dimensions) when open is called" + const container = terminalRef.current; + let ws: WebSocket; - // Connect WebSocket - const ws = connectWebSocket(); + const initTerminal = () => { + term.open(container); - // Fit after layout settles - use rAF + timeout to ensure DOM is ready - requestAnimationFrame(() => { + // Connect WebSocket + ws = connectWebSocket(); + + // Fit after layout settles requestAnimationFrame(() => { - fitAddon.fit(); - // Force a second fit after layout fully settles - setTimeout(() => { + requestAnimationFrame(() => { fitAddon.fit(); const { cols, rows } = term; if (ws.readyState === WebSocket.OPEN) { @@ -223,24 +227,58 @@ export const TerminalComponent: React.FC = ({ }) ); } - }, 500); - // Fit after mobile header auto-hides (3s delay + 0.3s transition) - setTimeout(() => { - fitAddon.fit(); - const { cols, rows } = term; - if (ws.readyState === WebSocket.OPEN) { - ws.send( - JSON.stringify({ - type: "resize", - cols, - rows, - }) - ); - } - }, 4000); + // Force a second fit after layout fully settles + setTimeout(() => { + fitAddon.fit(); + const { cols, rows } = term; + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "resize", + cols, + rows, + }) + ); + } + }, 500); + + // Fit after mobile header auto-hides (3s delay + 0.3s transition) + setTimeout(() => { + fitAddon.fit(); + const { cols, rows } = term; + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "resize", + cols, + rows, + }) + ); + } + }, 4000); + }); }); - }); + }; + + // Check if container already has dimensions + const rect = container.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + initTerminal(); + } else { + // Wait for container to be laid out + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + resizeObserver.disconnect(); + initTerminal(); + break; + } + } + }); + resizeObserver.observe(container); + } // Handle terminal input term.onData((data) => { @@ -281,6 +319,28 @@ export const TerminalComponent: React.FC = ({ window.addEventListener("resize", handleResize); + // Watch container size changes (e.g. mobile header auto-hide, keyboard) + const containerResizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + fitAddon.fit(); + const { cols, rows } = term; + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "resize", + cols, + rows, + }) + ); + } + } + } + }); + containerResizeObserver.observe(container); + containerResizeObserverRef.current = containerResizeObserver; + // Notify parent about terminal readiness if (onTerminalReadyRef.current) { const sendData = (data: string) => { @@ -299,7 +359,7 @@ export const TerminalComponent: React.FC = ({ // Visibility API for reconnection const handleVisibilityChange = () => { - if (document.visibilityState === "visible" && ws.readyState !== WebSocket.OPEN) { + if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) { reconnectAttemptsRef.current = 0; connectWebSocket(); } @@ -310,7 +370,13 @@ export const TerminalComponent: React.FC = ({ clearTimeout(resizeTimeout); window.removeEventListener("resize", handleResize); document.removeEventListener("visibilitychange", handleVisibilityChange); - ws.close(); + if (containerResizeObserverRef.current) { + containerResizeObserverRef.current.disconnect(); + containerResizeObserverRef.current = null; + } + if (ws) { + ws.close(); + } term.dispose(); }; // eslint-disable-next-line react-hooks/exhaustive-deps