From 1f164bc8d271588252120767276c1c8df907db40 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 8 Aug 2026 12:06:08 +0200 Subject: [PATCH] fix(mobile): only arm the viewport settle on a real keyboard transition A visualViewport resize event without a pending show/hide transition now only pushes a pending settle back (_deferViewportSettle) instead of arming fit + PTY-resize work of its own. Keyboard detection can miss a fine-grained OS animation entirely (each step under 150px, with the baseline chasing the animation down), while MobileDetection's own listener still shrinks --app-height, so the per-event settle fitted xterm against a mid-animation container with no keyboard CSS compensation and resized the PTY to transient dims. The resulting SIGWINCH thrash (58 -> 10 -> 50 rows) duplicated prompts and left tmux dot filler in the transcript on keyboard close. Reproduced with a faked visualViewport driving the real handler; master is unaffected because it never resized the PTY from this path. Co-Authored-By: Claude Fable 5 --- src/web/public/mobile-handlers.js | 28 +++++++++++++++++-- test/mobile/keyboard.test.ts | 46 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index ea1ed8f8..0cc7e860 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -215,6 +215,7 @@ const KeyboardHandler = { initialViewportHeight: 0, _viewportSettleTimer: null, _settleScrollToBottom: false, + _settlePending: false, /** Initialize keyboard handling */ init() { @@ -284,6 +285,7 @@ const KeyboardHandler = { this._viewportSettleTimer = null; } this._settleScrollToBottom = false; + this._settlePending = false; }, /** Handle viewport resize (keyboard show/hide) */ @@ -321,7 +323,7 @@ const KeyboardHandler = { } this.updateLayoutForKeyboard(); - this._scheduleViewportSettle(); + this._deferViewportSettle(); this.lastViewportHeight = currentHeight; }, @@ -446,12 +448,34 @@ const KeyboardHandler = { if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); }, - /** Coalesce the keyboard animation into one final xterm reflow and PTY resize. */ + /** + * Coalesce the keyboard animation into one final xterm reflow and PTY resize. + * Only a real show/hide transition arms the settle work; ongoing viewport + * resize events merely push a pending settle back (_deferViewportSettle). + * A viewport change that never crosses the show/hide thresholds must not + * refit: keyboard detection can miss a fine-grained OS animation entirely + * (each step under 150px, with the baseline chasing the animation), and the + * container is then mid-animation with no keyboard CSS compensation, so a + * fit against it resizes the PTY to transient dims and the SIGWINCH thrash + * garbles the transcript. + */ _scheduleViewportSettle({ scrollToBottom = false } = {}) { this._settleScrollToBottom = this._settleScrollToBottom || scrollToBottom; + this._settlePending = true; + this._armViewportSettleTimer(); + }, + + /** Push a pending settle back while the viewport is still animating; no-op otherwise. */ + _deferViewportSettle() { + if (!this._settlePending) return; + this._armViewportSettleTimer(); + }, + + _armViewportSettleTimer() { if (this._viewportSettleTimer) clearTimeout(this._viewportSettleTimer); this._viewportSettleTimer = setTimeout(() => { this._viewportSettleTimer = null; + this._settlePending = false; const shouldScrollToBottom = this._settleScrollToBottom; this._settleScrollToBottom = false; diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 721b9da3..f17cd104 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -417,6 +417,52 @@ describe('Virtual Keyboard', () => { expect(counts).toBe(1); }); + // A viewport resize with NO pending show/hide transition must not arm settle + // work of its own: keyboard detection can miss a fine-grained OS animation + // entirely (sub-150px steps with the baseline chasing the animation), and a + // fit against that mid-animation, uncompensated layout resizes the PTY to + // transient dims. The resulting SIGWINCH thrash duplicates prompts and + // garbles the transcript. Wiggles may only push a pending settle back. + it('does not refit on viewport wiggles without a keyboard transition', async () => { + const result = await page.evaluate(async () => { + const hadTerminal = app.terminal !== null && app.terminal !== undefined; + const hadFitAddon = app.fitAddon !== null && app.fitAddon !== undefined; + if (!hadTerminal) app.terminal = { scrollToBottom() {} }; + if (!hadFitAddon) app.fitAddon = { fit() {}, proposeDimensions: () => null }; + + const originalFit = app.fitAddon.fit.bind(app.fitAddon); + const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler); + let fits = 0; + app.fitAddon.fit = () => { + fits++; + }; + KeyboardHandler._sendTerminalResize = () => {}; + + // Wiggle only: nothing pending, so nothing may fire. + KeyboardHandler._deferViewportSettle(); + KeyboardHandler._deferViewportSettle(); + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 80)); + const wiggleOnly = fits; + + // A real transition arms the work; a following wiggle defers it but the + // settle still fires exactly once. + KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true }); + await new Promise((resolve) => setTimeout(resolve, 30)); + KeyboardHandler._deferViewportSettle(); + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 80)); + const afterTransition = fits; + + app.fitAddon.fit = originalFit; + KeyboardHandler._sendTerminalResize = originalSendResize; + if (!hadFitAddon) app.fitAddon = null; + if (!hadTerminal) app.terminal = null; + return { wiggleOnly, afterTransition }; + }); + + expect(result.wiggleOnly).toBe(0); + expect(result.afterTransition).toBe(1); + }); + it('accessory bar has the simple-mode action buttons', async () => { const actions = await page.evaluate(() => { return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(