fix(mobile): keep keyboard focus taps non-activating

This commit is contained in:
lior
2026-08-10 13:21:15 +03:00
parent 752374abc7
commit b01ed611c4
3 changed files with 71 additions and 4 deletions
+29 -2
View File
@@ -650,6 +650,7 @@ Object.assign(CodemanApp.prototype, {
let didScroll = false; // track whether touchmove fired (tap vs scroll)
let touchStartY = 0;
let tapCanActivateTerminal = false;
const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll
container.addEventListener(
'touchstart',
@@ -662,6 +663,7 @@ Object.assign(CodemanApp.prototype, {
pixelAccum = 0;
isTouching = true;
didScroll = false;
tapCanActivateTerminal = this._shouldForwardMobileTapToApp();
lastTime = 0;
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
@@ -737,9 +739,13 @@ Object.assign(CodemanApp.prototype, {
if (touch) {
this._suppressTrustedTapMouseEvents();
}
if (touch && mouseTrackingOn) {
if (touch && tapCanActivateTerminal && mouseTrackingOn) {
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
} else if (touch && this._sessionUsesServerMouseStrip()) {
} else if (
touch &&
tapCanActivateTerminal &&
this._sessionUsesServerMouseStrip()
) {
// The server strips mouse-tracking DECSETs from claude/codex/gemini
// output (isAltScreenStripMode, session.ts) so the wheel keeps
// scrolling scrollback — which leaves THIS xterm permanently at
@@ -759,6 +765,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus();
}
}
tapCanActivateTerminal = false;
},
{ passive: true }
);
@@ -3363,6 +3370,26 @@ Object.assign(CodemanApp.prototype, {
} catch {}
},
/**
* A tap that opens the phone keyboard is focus-only. Forwarding that same
* gesture as a mouse click would activate the highlighted CLI menu option.
* Once the keyboard and terminal input were already active at touchstart,
* later taps may intentionally position the cursor or select a TUI row.
*/
_shouldForwardMobileTapToApp() {
const keyboardVisible =
(typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) ||
document.body?.classList?.contains('keyboard-visible');
if (!keyboardVisible) return false;
const active = document.activeElement;
return (
active === this.terminal?.textarea ||
active?.classList?.contains('xterm-helper-textarea') ||
active?.id === 'cjkInput'
);
},
// ═══════════════════════════════════════════════════════════════
// Synthetic tap → mouse report
// ═══════════════════════════════════════════════════════════════
+10 -2
View File
@@ -756,12 +756,16 @@ describe('Virtual Keyboard', () => {
it('focuses the terminal helper textarea when the terminal is tapped', async () => {
await page.evaluate(() => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-focus-visible-input-test';
app.sessions.set('mobile-focus-visible-input-test', {
id: 'mobile-focus-visible-input-test',
mode: 'codex',
status: 'running',
});
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
app.hideWelcome();
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = false;
@@ -771,8 +775,12 @@ describe('Virtual Keyboard', () => {
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
const activeClass = await page.evaluate(() => document.activeElement?.className);
expect(activeClass).toContain('xterm-helper-textarea');
const state = await page.evaluate(() => ({
activeClass: document.activeElement?.className,
sentInputs: window.__sentInputs,
}));
expect(state.activeClass).toContain('xterm-helper-textarea');
expect(state.sentInputs).toEqual([]);
});
// Regression guard for the phone-keyboard blocker reduced in #173 and re-hit
+32
View File
@@ -6,8 +6,16 @@ import { describe, expect, it, vi } from 'vitest';
function loadTerminalUiHarness() {
const CodemanApp = function CodemanApp(this: any) {};
let now = 1_000;
let keyboardVisible = false;
let activeElement: unknown = null;
const context = vm.createContext({
window: {},
document: {
body: { classList: { contains: () => false } },
get activeElement() {
return activeElement;
},
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn() },
_crashDiag: { log: vi.fn() },
@@ -25,6 +33,11 @@ function loadTerminalUiHarness() {
MobileDetection: {
isTouchDevice: () => true,
},
KeyboardHandler: {
get keyboardVisible() {
return keyboardVisible;
},
},
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
@@ -38,6 +51,12 @@ function loadTerminalUiHarness() {
setNow: (value: number) => {
now = value;
},
setKeyboardVisible: (visible: boolean) => {
keyboardVisible = visible;
},
setActiveElement: (element: unknown) => {
activeElement = element;
},
};
}
@@ -56,6 +75,19 @@ function createElementHarness() {
}
describe('terminal touch tap mouse guard', () => {
it('keeps the keyboard-opening tap focus-only', () => {
const { app, setActiveElement, setKeyboardVisible } = loadTerminalUiHarness();
const textarea = { classList: { contains: () => true } };
app.terminal = { textarea };
setActiveElement(textarea);
setKeyboardVisible(false);
expect(app._shouldForwardMobileTapToApp()).toBe(false);
setKeyboardVisible(true);
expect(app._shouldForwardMobileTapToApp()).toBe(true);
});
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
const { app } = loadTerminalUiHarness();
const { element, dispatch } = createElementHarness();