mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
fix(mobile): coalesce keyboard viewport settling
This commit is contained in:
@@ -209,9 +209,12 @@ const MobileDetection = {
|
|||||||
* Also handles terminal scrolling and toolbar repositioning via visualViewport API.
|
* Also handles terminal scrolling and toolbar repositioning via visualViewport API.
|
||||||
*/
|
*/
|
||||||
const KeyboardHandler = {
|
const KeyboardHandler = {
|
||||||
|
VIEWPORT_SETTLE_MS: 80,
|
||||||
lastViewportHeight: 0,
|
lastViewportHeight: 0,
|
||||||
keyboardVisible: false,
|
keyboardVisible: false,
|
||||||
initialViewportHeight: 0,
|
initialViewportHeight: 0,
|
||||||
|
_viewportSettleTimer: null,
|
||||||
|
_settleScrollToBottom: false,
|
||||||
|
|
||||||
/** Initialize keyboard handling */
|
/** Initialize keyboard handling */
|
||||||
init() {
|
init() {
|
||||||
@@ -276,6 +279,11 @@ const KeyboardHandler = {
|
|||||||
window.removeEventListener('scroll', this._windowScrollHandler);
|
window.removeEventListener('scroll', this._windowScrollHandler);
|
||||||
this._windowScrollHandler = null;
|
this._windowScrollHandler = null;
|
||||||
}
|
}
|
||||||
|
if (this._viewportSettleTimer) {
|
||||||
|
clearTimeout(this._viewportSettleTimer);
|
||||||
|
this._viewportSettleTimer = null;
|
||||||
|
}
|
||||||
|
this._settleScrollToBottom = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Handle viewport resize (keyboard show/hide) */
|
/** Handle viewport resize (keyboard show/hide) */
|
||||||
@@ -313,6 +321,7 @@ const KeyboardHandler = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.updateLayoutForKeyboard();
|
this.updateLayoutForKeyboard();
|
||||||
|
this._scheduleViewportSettle();
|
||||||
this.lastViewportHeight = currentHeight;
|
this.lastViewportHeight = currentHeight;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -414,32 +423,9 @@ const KeyboardHandler = {
|
|||||||
// iOS Safari may scroll the document to reveal xterm's hidden textarea.
|
// iOS Safari may scroll the document to reveal xterm's hidden textarea.
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
// Refit terminal locally AND send resize to server so Claude Code (Ink)
|
// visualViewport emits multiple heights throughout the OS animation.
|
||||||
// knows the actual terminal dimensions. Without this, Ink redraws at the
|
// Re-schedule on every event and fit only after the final height settles.
|
||||||
// old (larger) row count when the user types, causing content to scroll
|
this._scheduleViewportSettle({ scrollToBottom: true });
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Reposition subagent windows to stack from bottom (above keyboard)
|
// Reposition subagent windows to stack from bottom (above keyboard)
|
||||||
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
||||||
@@ -454,22 +440,37 @@ const KeyboardHandler = {
|
|||||||
|
|
||||||
this.resetLayout();
|
this.resetLayout();
|
||||||
|
|
||||||
// Refit terminal, scroll to bottom, and send resize to restore original dimensions
|
this._scheduleViewportSettle({ scrollToBottom: true });
|
||||||
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);
|
|
||||||
|
|
||||||
// Reposition subagent windows to stack from top (below header)
|
// Reposition subagent windows to stack from top (below header)
|
||||||
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
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) */
|
/** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
|
||||||
_sendTerminalResize() {
|
_sendTerminalResize() {
|
||||||
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
|
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
|
||||||
|
|||||||
@@ -323,6 +323,44 @@ describe('Virtual Keyboard', () => {
|
|||||||
expect(mainPadding).toBe('');
|
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 () => {
|
it('accessory bar has the simple-mode action buttons', async () => {
|
||||||
const actions = await page.evaluate(() => {
|
const actions = await page.evaluate(() => {
|
||||||
return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
|
return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map(
|
||||||
|
|||||||
Reference in New Issue
Block a user