Improve mobile terminal input, selection, and resizing (#1607)

* Improve mobile terminal typing and selection

* Address mobile terminal review feedback

* Remove dead native terminal input branch

* Replay bracketed paste in terminal preamble

* Stop per-key terminal resize claims

* Stabilize terminal resize browser test

* Use tracked terminal mode for native paste

* Restore terminal toolbar focus handling

* Adapt native terminal to active visibility state

* Stabilize native mobile terminal interactions

* Add native terminal fallback boundary

* test(app): expect explicit terminal resize reclaim

* fix(app): stabilize terminal buffer coordinates

* fix(app): restrict terminal resize ownership

* fix(terminal): gate restored input modes

* fix(app): recover stalled terminal WebViews

* chore(app): remove obsolete terminal harnesses

* fix(app): make terminal resize claims explicit

* test(app): stabilize native terminal device flows

* test(app): track absolute scrollback coordinates

* fix(app): isolate terminal WebView streams

* fix(terminal): arbitrate size ownership across clients

The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it.

* test(server): make worker resize assertion portable

Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
This commit is contained in:
Mohamed Boudra
2026-08-03 19:01:34 +08:00
committed by GitHub
parent 2948a435ed
commit 04fe30a3e2
119 changed files with 9843 additions and 1032 deletions
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveMobilePanelGestureIntent } from "./gesture-intent";
describe("mobile panel gesture intent", () => {
it("blocks both panel-opening directions while the active surface owns horizontal dragging", () => {
expect([
resolveMobilePanelGestureIntent({
deltaX: 40,
deltaY: 2,
direction: 1,
openGesturesBlocked: true,
}),
resolveMobilePanelGestureIntent({
deltaX: -40,
deltaY: 2,
direction: -1,
openGesturesBlocked: true,
}),
]).toEqual(["fail", "fail"]);
});
it("keeps ordinary horizontal panel gestures available when unblocked", () => {
expect([
resolveMobilePanelGestureIntent({
deltaX: 40,
deltaY: 2,
direction: 1,
openGesturesBlocked: false,
}),
resolveMobilePanelGestureIntent({
deltaX: -40,
deltaY: 2,
direction: -1,
openGesturesBlocked: false,
}),
]).toEqual(["activate", "activate"]);
});
});
@@ -0,0 +1,24 @@
export type MobilePanelGestureDirection = -1 | 1;
export type MobilePanelGestureIntent = "activate" | "fail" | "wait";
export function resolveMobilePanelGestureIntent(input: {
deltaX: number;
deltaY: number;
direction: MobilePanelGestureDirection;
openGesturesBlocked: boolean;
}): MobilePanelGestureIntent {
"worklet";
if (input.openGesturesBlocked) {
return "fail";
}
const directedDelta = input.deltaX * input.direction;
const absDeltaX = Math.abs(input.deltaX);
const absDeltaY = Math.abs(input.deltaY);
if (directedDelta <= -10 || (absDeltaY > 10 && absDeltaY > absDeltaX)) {
return "fail";
}
if (directedDelta >= 15 && absDeltaX > absDeltaY) {
return "activate";
}
return "wait";
}
+37 -35
View File
@@ -7,36 +7,14 @@ import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-contex
import { usePanelStore } from "@/stores/panel-store";
import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model";
import { useMobilePanelsRuntime } from "./provider";
import { resolveMobilePanelGestureIntent } from "./gesture-intent";
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
const PAN_INTENT_FAIL = -1;
const PAN_INTENT_WAIT = 0;
const PAN_INTENT_ACTIVATE = 1;
type PanDirection = -1 | 1;
type PanIntent = typeof PAN_INTENT_ACTIVATE | typeof PAN_INTENT_FAIL | typeof PAN_INTENT_WAIT;
function isCurrentSelection(startedRevision: number): boolean {
return usePanelStore.getState().mobilePanel.revision === startedRevision;
}
function getHorizontalPanIntent(
deltaX: number,
deltaY: number,
direction: PanDirection,
): PanIntent {
"worklet";
const directedDelta = deltaX * direction;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (directedDelta <= -10 || (absDeltaY > 10 && absDeltaY > absDeltaX)) {
return PAN_INTENT_FAIL;
}
if (directedDelta >= 15 && absDeltaX > absDeltaY) {
return PAN_INTENT_ACTIVATE;
}
return PAN_INTENT_WAIT;
}
function useGestureState() {
return {
startedRevision: useSharedValue(-1),
@@ -62,6 +40,7 @@ export function useOpenAgentListGesture(enabled: boolean) {
finishGesture,
leftOpenGestureRef,
motionState,
openGesturesBlocked,
position,
updateGesture,
windowWidth,
@@ -96,17 +75,22 @@ export function useOpenAgentListGesture(enabled: boolean) {
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, 1);
const panIntent = resolveMobilePanelGestureIntent({
deltaX,
deltaY,
direction: 1,
openGesturesBlocked: openGesturesBlocked.value,
});
if (
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
horizontalScroll?.isAnyScrolledRight.value ||
(isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
panIntent === PAN_INTENT_FAIL
panIntent === "fail"
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
if (panIntent === "activate") {
stateManager.activate();
}
})
@@ -135,6 +119,7 @@ export function useOpenAgentListGesture(enabled: boolean) {
horizontalScroll?.isAnyScrolledRight,
leftOpenGestureRef,
motionState,
openGesturesBlocked,
position,
startedRevision,
touchStartX,
@@ -183,15 +168,20 @@ export function useCloseAgentListGesture() {
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, -1);
const panIntent = resolveMobilePanelGestureIntent({
deltaX,
deltaY,
direction: -1,
openGesturesBlocked: false,
});
if (
!canBeginMobilePanelGesture(motionState.value, "agent-list", position.value) ||
panIntent === PAN_INTENT_FAIL
panIntent === "fail"
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
if (panIntent === "activate") {
stateManager.activate();
}
})
@@ -244,6 +234,7 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer
finishGesture,
leftOpenGestureRef,
motionState,
openGesturesBlocked,
position,
rightOpenGestureRef,
updateGesture,
@@ -278,16 +269,21 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, -1);
const panIntent = resolveMobilePanelGestureIntent({
deltaX,
deltaY,
direction: -1,
openGesturesBlocked: openGesturesBlocked.value,
});
if (
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
(isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
panIntent === PAN_INTENT_FAIL
panIntent === "fail"
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
if (panIntent === "activate") {
stateManager.activate();
}
})
@@ -318,6 +314,7 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer
finishGesture,
leftOpenGestureRef,
motionState,
openGesturesBlocked,
position,
rightOpenGestureRef,
startedRevision,
@@ -367,15 +364,20 @@ export function useCloseFileExplorerGesture() {
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, 1);
const panIntent = resolveMobilePanelGestureIntent({
deltaX,
deltaY,
direction: 1,
openGesturesBlocked: false,
});
if (
!canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) ||
panIntent === PAN_INTENT_FAIL
panIntent === "fail"
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
if (panIntent === "activate") {
stateManager.activate();
}
})
+39 -1
View File
@@ -3,6 +3,7 @@ import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -57,10 +58,12 @@ interface MobilePanelsRuntime {
leftCloseGestureRef: RefObject<GestureType | undefined>;
leftOpenGestureRef: RefObject<GestureType | undefined>;
motionState: SharedValue<MobilePanelMotionState>;
openGesturesBlocked: SharedValue<boolean>;
position: SharedValue<number>;
rightCloseGestureRef: RefObject<GestureType | undefined>;
rightOpenGestureRef: RefObject<GestureType | undefined>;
updateGesture: (startedRevision: number, nextPosition: number) => boolean;
setOpenGestureBlocked: (owner: symbol, blocked: boolean) => void;
windowWidth: number;
}
@@ -83,12 +86,26 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
const initialSelection = useRef(usePanelStore.getState().mobilePanel).current;
const position = useSharedValue(getMobilePanelAnchor(initialSelection.target));
const motionState = useSharedValue(createMobilePanelMotionState(initialSelection));
const openGesturesBlocked = useSharedValue(false);
const openGestureBlockersRef = useRef(new Set<symbol>());
const leftOpenGestureRef = useRef<GestureType | undefined>(undefined);
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
const rightCloseGestureRef = useRef<GestureType | undefined>(undefined);
const [presentedPanels, setPresentedPanels] = useState(getPanelMask(initialSelection.target));
const setOpenGestureBlocked = useCallback(
(owner: symbol, blocked: boolean) => {
if (blocked) {
openGestureBlockersRef.current.add(owner);
} else {
openGestureBlockersRef.current.delete(owner);
}
openGesturesBlocked.value = openGestureBlockersRef.current.size > 0;
},
[openGesturesBlocked],
);
const presentPanel = useCallback((panel: MobilePanelView) => {
const mask = getPanelMask(panel);
if (mask) {
@@ -227,13 +244,24 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
leftCloseGestureRef,
leftOpenGestureRef,
motionState,
openGesturesBlocked,
position,
rightCloseGestureRef,
rightOpenGestureRef,
updateGesture,
setOpenGestureBlocked,
windowWidth,
}),
[beginGesture, finishGesture, motionState, position, updateGesture, windowWidth],
[
beginGesture,
finishGesture,
motionState,
openGesturesBlocked,
position,
setOpenGestureBlocked,
updateGesture,
windowWidth,
],
);
return (
@@ -258,3 +286,13 @@ export function useIsMobilePanelPresented(panel: MobilePanelView): boolean {
const presentedPanels = useContext(MobilePanelPresentationContext);
return (presentedPanels & getPanelMask(panel)) !== 0;
}
export function useBlockMobilePanelOpenGestures(blocked: boolean): void {
const { setOpenGestureBlocked } = useMobilePanelsRuntime();
const owner = useRef(Symbol("mobile-panel-open-gesture-blocker")).current;
useLayoutEffect(() => {
setOpenGestureBlocked(owner, blocked);
return () => setOpenGestureBlocked(owner, false);
}, [blocked, owner, setOpenGestureBlocked]);
}