From 66abe6c70a6203a140cf79e2ba98e904d445c6c8 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 12:17:39 +0300 Subject: [PATCH] fix(mobile): coalesce keyboard viewport settling --- src/web/public/mobile-handlers.js | 75 ++++++++++++++++--------------- test/mobile/keyboard.test.ts | 38 ++++++++++++++++ 2 files changed, 76 insertions(+), 37 deletions(-) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 5b8b99a5..ea1ed8f8 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -209,9 +209,12 @@ const MobileDetection = { * Also handles terminal scrolling and toolbar repositioning via visualViewport API. */ const KeyboardHandler = { + VIEWPORT_SETTLE_MS: 80, lastViewportHeight: 0, keyboardVisible: false, initialViewportHeight: 0, + _viewportSettleTimer: null, + _settleScrollToBottom: false, /** Initialize keyboard handling */ init() { @@ -276,6 +279,11 @@ const KeyboardHandler = { window.removeEventListener('scroll', this._windowScrollHandler); this._windowScrollHandler = null; } + if (this._viewportSettleTimer) { + clearTimeout(this._viewportSettleTimer); + this._viewportSettleTimer = null; + } + this._settleScrollToBottom = false; }, /** Handle viewport resize (keyboard show/hide) */ @@ -313,6 +321,7 @@ const KeyboardHandler = { } this.updateLayoutForKeyboard(); + this._scheduleViewportSettle(); this.lastViewportHeight = currentHeight; }, @@ -414,32 +423,9 @@ const KeyboardHandler = { // iOS Safari may scroll the document to reveal xterm's hidden textarea. window.scrollTo(0, 0); - // Refit terminal locally AND send resize to server so Claude Code (Ink) - // knows the actual terminal dimensions. Without this, Ink redraws at the - // old (larger) row count when the user types, causing content to scroll - // off the visible area with each keystroke. - // Note: the throttledResize handler still suppresses ongoing resize events - // while keyboard is up — this one-shot resize on open/close is sufficient. - setTimeout(() => { - if (typeof app !== 'undefined' && app.terminal) { - if (app.fitAddon) - try { - app.fitAddon.fit(); - } catch {} - // Eliminate terminal row quantization gap: xterm can only show whole - // rows, so leftover pixels create dead space below the last row. - // Shrink .main's paddingBottom by the gap so the terminal fills flush - // to the accessory bar. - this._shrinkPaddingToFit(); - app.terminal.scrollToBottom(); - app._syncMobileHelperTextareaToCursor?.(); - app._localEchoOverlay?.rerender?.(); - // Send resize to server so PTY dimensions match xterm - this._sendTerminalResize(); - } - // Reset again after fit/resize in case layout changes triggered scroll - window.scrollTo(0, 0); - }, 150); + // visualViewport emits multiple heights throughout the OS animation. + // Re-schedule on every event and fit only after the final height settles. + this._scheduleViewportSettle({ scrollToBottom: true }); // Reposition subagent windows to stack from bottom (above keyboard) if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); @@ -454,22 +440,37 @@ const KeyboardHandler = { this.resetLayout(); - // Refit terminal, scroll to bottom, and send resize to restore original dimensions - setTimeout(() => { - if (typeof app !== 'undefined' && app.fitAddon) { - try { - app.fitAddon.fit(); - } catch {} - if (app.terminal) app.terminal.scrollToBottom(); - // Send resize to server to restore full terminal size - this._sendTerminalResize(); - } - }, 100); + this._scheduleViewportSettle({ scrollToBottom: true }); // Reposition subagent windows to stack from top (below header) if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); }, + /** Coalesce the keyboard animation into one final xterm reflow and PTY resize. */ + _scheduleViewportSettle({ scrollToBottom = false } = {}) { + this._settleScrollToBottom = this._settleScrollToBottom || scrollToBottom; + if (this._viewportSettleTimer) clearTimeout(this._viewportSettleTimer); + this._viewportSettleTimer = setTimeout(() => { + this._viewportSettleTimer = null; + const shouldScrollToBottom = this._settleScrollToBottom; + this._settleScrollToBottom = false; + + if (typeof app !== 'undefined' && app.terminal) { + if (app.fitAddon) { + try { + app.fitAddon.fit(); + } catch {} + } + if (this.keyboardVisible) this._shrinkPaddingToFit(); + if (shouldScrollToBottom) app.terminal.scrollToBottom(); + app._syncMobileHelperTextareaToCursor?.(); + app._localEchoOverlay?.rerender?.(); + this._sendTerminalResize(); + } + window.scrollTo(0, 0); + }, this.VIEWPORT_SETTLE_MS); + }, + /** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */ _sendTerminalResize() { if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return; diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 5032f653..a99ae023 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -323,6 +323,44 @@ describe('Virtual Keyboard', () => { expect(mainPadding).toBe(''); }); + it('coalesces keyboard animation frames into one final terminal fit', async () => { + const result = await page.evaluate(async () => { + const originalFit = app.fitAddon.fit.bind(app.fitAddon); + const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler); + const originalScrollToBottom = app.terminal.scrollToBottom.bind(app.terminal); + let fits = 0; + let resizes = 0; + let bottomRestores = 0; + app.fitAddon.fit = () => { + fits++; + }; + KeyboardHandler._sendTerminalResize = () => { + resizes++; + }; + app.terminal.scrollToBottom = () => { + bottomRestores++; + }; + + KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true }); + await new Promise((resolve) => setTimeout(resolve, 30)); + KeyboardHandler._scheduleViewportSettle(); + await new Promise((resolve) => setTimeout(resolve, 30)); + KeyboardHandler._scheduleViewportSettle(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const beforeFinalSettle = { fits, resizes, bottomRestores }; + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS)); + const afterFinalSettle = { fits, resizes, bottomRestores }; + + app.fitAddon.fit = originalFit; + KeyboardHandler._sendTerminalResize = originalSendResize; + app.terminal.scrollToBottom = originalScrollToBottom; + return { beforeFinalSettle, afterFinalSettle }; + }); + + expect(result.beforeFinalSettle).toEqual({ fits: 0, resizes: 0, bottomRestores: 0 }); + expect(result.afterFinalSettle).toEqual({ fits: 1, resizes: 1, bottomRestores: 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(