From a4f433195da378eb3a6abfa4998ab63c2268e168 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 23 Mar 2026 13:31:07 +0700 Subject: [PATCH] fix(terminal): only send resize from the focused client Multiple clients viewing the same terminal would fight over PTY size because every client sent resizes independently. Gate resize sends on three focus levels: pane focus (split panes), screen focus (navigation), and app visibility (browser window / mobile foreground). When a client regains focus, clear the last reported size and trigger a reflow to reclaim the PTY dimensions. --- packages/app/src/components/terminal-pane.tsx | 17 +++++-- packages/app/src/hooks/use-app-visible.ts | 49 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/hooks/use-app-visible.ts diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 14f603b02..87baf954c 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -6,6 +6,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useAppVisible } from "@/hooks/use-app-visible"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { hasPendingTerminalModifiers, normalizeTerminalTransportKey, @@ -86,6 +88,7 @@ export function TerminalPane({ isPaneFocused, }: TerminalPaneProps) { const isScreenFocused = useIsFocused(); + const isAppVisible = useAppVisible(); const { theme } = useUnistyles(); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -143,6 +146,13 @@ export function TerminalPane({ requestTerminalFocus(); }, [isMobile, isPaneFocused, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]); + useEffect(() => { + if (isPaneFocused && isScreenFocused && isAppVisible && terminalId) { + lastReportedSizeRef.current = null; + requestTerminalReflow(); + } + }, [isAppVisible, isPaneFocused, isScreenFocused, requestTerminalReflow, terminalId]); + const clearKeyboardRefitTimeouts = useCallback(() => { if (keyboardRefitTimeoutsRef.current.length === 0) { return; @@ -462,10 +472,10 @@ export function TerminalPane({ ], ); - const handleTerminalResize = useCallback( - async (input: { rows: number; cols: number }) => { + const handleTerminalResize = useStableEvent( + (input: { rows: number; cols: number }) => { const { rows, cols } = input; - if (!client || !terminalId || rows <= 0 || cols <= 0) { + if (!client || !terminalId || !isPaneFocused || !isScreenFocused || !isAppVisible || rows <= 0 || cols <= 0) { return; } const normalizedRows = Math.floor(rows); @@ -481,7 +491,6 @@ export function TerminalPane({ cols: normalizedCols, }); }, - [client, terminalId], ); const handleTerminalKey = useCallback( diff --git a/packages/app/src/hooks/use-app-visible.ts b/packages/app/src/hooks/use-app-visible.ts new file mode 100644 index 000000000..1d584121a --- /dev/null +++ b/packages/app/src/hooks/use-app-visible.ts @@ -0,0 +1,49 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { AppState, Platform } from "react-native"; +import { getIsAppActivelyVisible } from "@/utils/app-visibility"; + +let current = getIsAppActivelyVisible(); +const listeners = new Set<() => void>(); + +function notify(): void { + const next = getIsAppActivelyVisible(); + if (next === current) { + return; + } + current = next; + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): boolean { + return current; +} + +export function useAppVisible(): boolean { + useEffect(() => { + const appStateSubscription = AppState.addEventListener("change", notify); + + if (Platform.OS === "web" && typeof document !== "undefined") { + document.addEventListener("visibilitychange", notify); + window.addEventListener("focus", notify); + window.addEventListener("blur", notify); + } + + return () => { + appStateSubscription.remove(); + if (Platform.OS === "web" && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", notify); + window.removeEventListener("focus", notify); + window.removeEventListener("blur", notify); + } + }; + }, []); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}