mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #229 from Lint111/feat/keyboard-viewport-settle
fix(mobile): coalesce keyboard viewport settling
This commit is contained in:
@@ -209,9 +209,13 @@ 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,
|
||||
_settlePending: false,
|
||||
|
||||
/** Initialize keyboard handling */
|
||||
init() {
|
||||
@@ -276,6 +280,12 @@ const KeyboardHandler = {
|
||||
window.removeEventListener('scroll', this._windowScrollHandler);
|
||||
this._windowScrollHandler = null;
|
||||
}
|
||||
if (this._viewportSettleTimer) {
|
||||
clearTimeout(this._viewportSettleTimer);
|
||||
this._viewportSettleTimer = null;
|
||||
}
|
||||
this._settleScrollToBottom = false;
|
||||
this._settlePending = false;
|
||||
},
|
||||
|
||||
/** Handle viewport resize (keyboard show/hide) */
|
||||
@@ -313,6 +323,7 @@ const KeyboardHandler = {
|
||||
}
|
||||
|
||||
this.updateLayoutForKeyboard();
|
||||
this._deferViewportSettle();
|
||||
this.lastViewportHeight = currentHeight;
|
||||
},
|
||||
|
||||
@@ -414,32 +425,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 +442,59 @@ 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.
|
||||
* 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;
|
||||
|
||||
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;
|
||||
|
||||
@@ -323,6 +323,146 @@ describe('Virtual Keyboard', () => {
|
||||
expect(mainPadding).toBe('');
|
||||
});
|
||||
|
||||
it('coalesces keyboard animation frames into one final terminal fit', async () => {
|
||||
const result = await page.evaluate(async () => {
|
||||
// `app.terminal` and `app.fitAddon` are only assigned by initTerminal(),
|
||||
// which needs a selected session this harness never creates. Both are
|
||||
// null at rest, and the settle callback returns early on a falsy
|
||||
// terminal — so without stand-ins this test cannot reach the behavior
|
||||
// it asserts. Install the minimum surface the callback touches.
|
||||
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);
|
||||
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;
|
||||
if (!hadFitAddon) app.fitAddon = null;
|
||||
if (!hadTerminal) app.terminal = null;
|
||||
return { beforeFinalSettle, afterFinalSettle };
|
||||
});
|
||||
|
||||
expect(result.beforeFinalSettle).toEqual({ fits: 0, resizes: 0, bottomRestores: 0 });
|
||||
expect(result.afterFinalSettle).toEqual({ fits: 1, resizes: 1, bottomRestores: 1 });
|
||||
});
|
||||
|
||||
// Behavioral counterpart to the test above, driven through the PUBLIC entry
|
||||
// point rather than the internal scheduler. Before this change each
|
||||
// onKeyboardShow armed its own uncoalesced 150ms setTimeout, so a keyboard
|
||||
// animation that reports several viewport steps refit the terminal once per
|
||||
// step — the visible symptom being repeated reflow while the keyboard slides
|
||||
// up. This asserts the observable outcome (one refit for a burst) and so
|
||||
// fails on master by COUNT, not by a missing method.
|
||||
it('refits once for a burst of keyboard viewport steps', async () => {
|
||||
const counts = 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 = () => {};
|
||||
|
||||
// Three viewport steps in quick succession, as a keyboard animation
|
||||
// produces on a real device.
|
||||
KeyboardHandler.onKeyboardShow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
KeyboardHandler.onKeyboardShow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
KeyboardHandler.onKeyboardShow();
|
||||
|
||||
// Well past both the coalescing window and master's fixed 150ms timer.
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
|
||||
app.fitAddon.fit = originalFit;
|
||||
KeyboardHandler._sendTerminalResize = originalSendResize;
|
||||
if (!hadFitAddon) app.fitAddon = null;
|
||||
if (!hadTerminal) app.terminal = null;
|
||||
return fits;
|
||||
});
|
||||
|
||||
// Coalesced: one refit for the whole burst. Master fires one per step.
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user