fix(mobile): close audit findings — desktop focus, claim wiring, CJK setting, ESC passthrough

Adversarial post-rebase audit (11 agents) confirmed four real issues;
all fixed:

- Desktop tab clicks stopped focusing the terminal: handleSessionTabClick
  passed preserveKeyboard:false on desktop (KeyboardHandler.keyboardVisible
  is mobile-only state) and selectSession's ternary mapped explicit false
  to 'never focus', skipping the gesture-stack focus master relies on.
  Focus policy now lives solely in _shouldFocusTerminalForTabSwitch()
  (desktop: always; touch: only while the keyboard is open).

- Desktop sizing claims were almost never registered: selectSession's
  resizes run before _connectWs, so they went over HTTP (which never
  claims), leaving the arbitration inert in the canonical desktop+phone
  scenario. ws.onopen now sends a typed resize over the fresh socket —
  registering the claim and syncing PTY dims after (re)connects.

- throttledResize (the main window-resize path) sent untyped HTTP
  resizes: a rotating phone bypassed a desktop claim, and a desktop
  narrowing past the tablet breakpoint never released its stale claim.
  It now sends typed resizes, WS-first, like sendResize.

- The cjkInputEnabled App Settings toggle was silently ignored on touch
  phones/tablets (composer only reachable via the server inputCjkForm
  override, while the checkbox stayed visible and saveable). The user
  setting is honored everywhere again; mobile keeps native-input-by-
  default via the cjkInputEnabled:false mobile default.

- _handleCjkInput appended multi-byte ESC sequences (hardware-keyboard
  arrows/Home/End on the composer) to local-echo pending text, typing
  raw ESC bytes into the prompt on Enter; they are now forwarded to the
  PTY like the onData path. Its backspace path also syncs the
  per-session flushed Maps the way onData does, so tab-switch restore
  no longer resurrects deleted characters.

Defensive: Session.stop() clears desktop sizing claims (a hung client's
socket close can lag teardown by a ping cycle), and the claims docblock
documents the WS-only tradeoff explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-10 04:31:45 +02:00
co-authored by Claude Opus 4.8
parent e77df131b8
commit 7a39fd9a77
3 changed files with 79 additions and 21 deletions
+18 -15
View File
@@ -1838,6 +1838,11 @@ class CodemanApp {
if (this._ws === ws) {
this._wsReady = true;
this._wsReconnectAttempts = 0;
// Send a typed resize over the fresh socket: syncs PTY dims after
// (re)connects AND registers the desktop sizing claim server-side —
// selectSession's earlier resizes ran before this WS existed, so they
// went over HTTP, which never claims (see ws-routes sizingToken).
this.sendResize(sessionId)?.catch?.(() => {});
}
};
@@ -2028,12 +2033,10 @@ class CodemanApp {
if (!cjkEl) return;
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings?.() || {};
const isTouchTerminal =
typeof MobileDetection !== 'undefined' &&
MobileDetection.isTouchDevice() &&
(MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen());
const showCjk =
this._serverCjkOverride || (!isTouchTerminal && (settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false));
// Mobile defaults ship cjkInputEnabled: false (native terminal input by
// default on touch), but an explicit user enable is honored everywhere —
// the App Settings toggle must not be a silent no-op on phones.
const showCjk = this._serverCjkOverride || (settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false);
cjkEl.classList.toggle('cjk-input-visible', !!showCjk);
document.body.classList.toggle('cjk-input-visible', !!showCjk);
cjkEl.style.display = showCjk ? 'block' : 'none';
@@ -2646,11 +2649,14 @@ class CodemanApp {
handleSessionTabClick(event, sessionId) {
event?.preventDefault?.();
const preserveKeyboard = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible === true;
if (!preserveKeyboard && MobileDetection.isTouchDevice()) {
// On touch with the keyboard hidden, blur the tapped tab so switching
// sessions doesn't pop the on-screen keyboard. Focus policy itself lives
// in selectSession via _shouldFocusTerminalForTabSwitch().
const keyboardOpen = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible === true;
if (!keyboardOpen && MobileDetection.isTouchDevice()) {
document.activeElement?.blur?.();
}
return this.selectSession(sessionId, { forceReload: true, preserveKeyboard });
return this.selectSession(sessionId, { forceReload: true });
}
@@ -2954,12 +2960,9 @@ class CodemanApp {
// programmatic focus() within the user-gesture call stack (e.g. tab click).
// After the first await the gesture context is lost and focus() is silently
// ignored, leaving the keyboard unable to send input to the terminal.
const shouldFocusTerminal =
options?.preserveKeyboard === true
? this._shouldFocusTerminalForTabSwitch()
: options?.preserveKeyboard === false
? false
: this._shouldFocusTerminalForTabSwitch();
// Desktop always focuses; touch focuses only while the on-screen keyboard
// is already open (so a tab switch doesn't pop the keyboard).
const shouldFocusTerminal = this._shouldFocusTerminalForTabSwitch();
if (shouldFocusTerminal && this.terminal) this.terminal.focus();
const _selStart = performance.now();