mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -215,6 +215,7 @@ const KeyboardHandler = {
|
|||||||
initialViewportHeight: 0,
|
initialViewportHeight: 0,
|
||||||
_viewportSettleTimer: null,
|
_viewportSettleTimer: null,
|
||||||
_settleScrollToBottom: false,
|
_settleScrollToBottom: false,
|
||||||
|
_settlePending: false,
|
||||||
|
|
||||||
/** Initialize keyboard handling */
|
/** Initialize keyboard handling */
|
||||||
init() {
|
init() {
|
||||||
@@ -284,6 +285,7 @@ const KeyboardHandler = {
|
|||||||
this._viewportSettleTimer = null;
|
this._viewportSettleTimer = null;
|
||||||
}
|
}
|
||||||
this._settleScrollToBottom = false;
|
this._settleScrollToBottom = false;
|
||||||
|
this._settlePending = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Handle viewport resize (keyboard show/hide) */
|
/** Handle viewport resize (keyboard show/hide) */
|
||||||
@@ -321,7 +323,7 @@ const KeyboardHandler = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.updateLayoutForKeyboard();
|
this.updateLayoutForKeyboard();
|
||||||
this._scheduleViewportSettle();
|
this._deferViewportSettle();
|
||||||
this.lastViewportHeight = currentHeight;
|
this.lastViewportHeight = currentHeight;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -446,12 +448,34 @@ const KeyboardHandler = {
|
|||||||
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
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 } = {}) {
|
_scheduleViewportSettle({ scrollToBottom = false } = {}) {
|
||||||
this._settleScrollToBottom = this._settleScrollToBottom || scrollToBottom;
|
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);
|
if (this._viewportSettleTimer) clearTimeout(this._viewportSettleTimer);
|
||||||
this._viewportSettleTimer = setTimeout(() => {
|
this._viewportSettleTimer = setTimeout(() => {
|
||||||
this._viewportSettleTimer = null;
|
this._viewportSettleTimer = null;
|
||||||
|
this._settlePending = false;
|
||||||
const shouldScrollToBottom = this._settleScrollToBottom;
|
const shouldScrollToBottom = this._settleScrollToBottom;
|
||||||
this._settleScrollToBottom = false;
|
this._settleScrollToBottom = false;
|
||||||
|
|
||||||
|
|||||||
@@ -417,6 +417,52 @@ describe('Virtual Keyboard', () => {
|
|||||||
expect(counts).toBe(1);
|
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 () => {
|
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