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(