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.
This commit is contained in:
Mohamed Boudra
2026-03-23 13:31:07 +07:00
parent 12ea75ab65
commit a4f433195d
2 changed files with 62 additions and 4 deletions
+13 -4
View File
@@ -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(
+49
View File
@@ -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);
}