mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
fix(mobile): keep the keyboard reachable when the viewport is scrolled up
Addresses the review on #244. BLOCKING (item 1). selectSession() ends with scrollToLastNonEmptyLine(), which parks the viewport above the bottom for any session taller than the screen, so after a tab switch every tap classified as 'history' — touchstart ran preventDefault() + blur, and touchend's early return skipped focus. Both routes to focus closed on one gesture, the same mechanism as #173. Suppressing the mouse REPORT while scrolled up is right and is kept; suppressing FOCUS is not. touchstart now only preventDefaults 'content' taps (a scrolled-up viewport sends nothing, so there is no compatibility click worth cancelling), and the 'history' branch focuses instead of blurring. Verified against the maintainer's own test, which was already on master and red: `keeps the terminal input focusable after a tab switch parks the viewport off-bottom` fails without this change and passes with it. Item 2: dropped both `terminal-action-pending` guards. The class exists nowhere in the repo, so both branches were permanently false and the comment promised coverage that did not exist. Item 3: removed the `Working` literals. Live claude 2.1.226 prints "Cooked for 2m 6s" with a different bullet and a randomised verb, so they were dead code. The status row is matched by its affordance ("esc to interrupt") instead, which is what makes it actionable. The affordance regex is also tightened to require a key or gesture name, so prose like "click here to open the file" no longer dismisses the keyboard. Item 4: removed _shouldForwardTouchScrollToApp and its test. It was never called, and wiring it as written would have restricted forwarding to claude only, dropping gemini from the path #205 established — a behaviour change this PR has no reason to make. Smaller items: the touchstart classification is cached and reused for the touchend of the same gesture (keyed on exact coordinates, so a moved finger re-classifies), removing two of the three full-viewport scans per gesture; the duplicated touchLastX assignment is gone; and the no-touch bail-out returns null rather than claiming 'history'. test/mobile/keyboard.test.ts: 51 tests, 5 failed | 46 passed — the same 5 pre-existing failures as master, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -756,6 +756,94 @@ describe('Virtual Keyboard', () => {
|
||||
|
||||
it('focuses the terminal helper textarea when the terminal is tapped', async () => {
|
||||
await page.evaluate(() => {
|
||||
app.activeSessionId = 'mobile-focus-visible-input-test';
|
||||
app.sessions.set('mobile-focus-visible-input-test', {
|
||||
id: 'mobile-focus-visible-input-test',
|
||||
mode: 'codex',
|
||||
status: 'running',
|
||||
});
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
});
|
||||
|
||||
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
|
||||
|
||||
const activeClass = await page.evaluate(() => document.activeElement?.className);
|
||||
expect(activeClass).toContain('xterm-helper-textarea');
|
||||
});
|
||||
|
||||
// Regression guard for the phone-keyboard blocker reduced in #173 and re-hit
|
||||
// by #244. selectSession() ends with scrollToLastNonEmptyLine(), which parks
|
||||
// the viewport ABOVE the bottom for any session whose buffer is taller than
|
||||
// the screen and ends in blank rows, i.e. every real session after a tab
|
||||
// switch. A tap-routing scheme that treats "viewport is scrolled up" as a
|
||||
// reason to blur strands document.activeElement on <body> with no way to
|
||||
// raise the keyboard, and the prompt row is no exception. Suppressing the
|
||||
// MOUSE REPORT while scrolled up is correct and pinned below; suppressing
|
||||
// FOCUS is not. Measured against PR #244 on 2026-08-09: body vs textarea.
|
||||
//
|
||||
// Must be a dispatched gesture: calling the touchend handler directly
|
||||
// bypasses touchstart's preventDefault, which is half of what closes the
|
||||
// focus path, so a direct call reports the right intent and still misses.
|
||||
it('keeps the terminal input focusable after a tab switch parks the viewport off-bottom', async () => {
|
||||
const probe = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-offbottom-tap-test';
|
||||
app.sessions.set('mobile-offbottom-tap-test', {
|
||||
id: 'mobile-offbottom-tap-test',
|
||||
mode: 'claude',
|
||||
cliVersion: '2.1.220',
|
||||
status: 'running',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app.terminal.reset();
|
||||
|
||||
// Taller than the viewport, ending in the trailing blank rows that make
|
||||
// scrollToLastNonEmptyLine() stop short of the bottom.
|
||||
const lines: string[] = [];
|
||||
for (let i = 1; i <= app.terminal.rows * 3; i++) lines.push(`Transcript row ${i}`);
|
||||
lines.push('', '❯ ', '', '');
|
||||
await new Promise<void>((resolve) => app.terminal.write(lines.join('\r\n'), resolve));
|
||||
|
||||
app.scrollToLastNonEmptyLine(); // what selectSession() does on every tab switch
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!rect || !cell?.width || !cell?.height) return null;
|
||||
const buffer = app.terminal.buffer.active;
|
||||
return {
|
||||
x: rect.left + cell.width * 2,
|
||||
y: rect.top + cell.height * 5.5,
|
||||
atBottom: buffer.viewportY >= buffer.baseY,
|
||||
};
|
||||
});
|
||||
|
||||
expect(probe).not.toBeNull();
|
||||
// The guard only means anything if the viewport really did park off-bottom.
|
||||
expect(probe!.atBottom).toBe(false);
|
||||
|
||||
await page.touchscreen.tap(probe!.x, probe!.y);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(state.activeClass).toContain('xterm-helper-textarea');
|
||||
// SGR coordinates are meaningless off-bottom, so the tap must stay silent.
|
||||
expect(state.sentInputs).toEqual([]);
|
||||
});
|
||||
|
||||
it('collapses a terminal readback without focusing the hidden textarea', async () => {
|
||||
const point = await page.evaluate(async () => {
|
||||
@@ -961,42 +1049,6 @@ describe('Virtual Keyboard', () => {
|
||||
mode: 'codex',
|
||||
status: 'running',
|
||||
});
|
||||
app.hideWelcome();
|
||||
const settings = app.loadAppSettingsFromStorage();
|
||||
settings.cjkInputEnabled = false;
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
});
|
||||
|
||||
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
|
||||
|
||||
const activeClass = await page.evaluate(() => document.activeElement?.className);
|
||||
expect(activeClass).toContain('xterm-helper-textarea');
|
||||
});
|
||||
|
||||
// Regression guard for the phone-keyboard blocker reduced in #173 and re-hit
|
||||
// by #244. selectSession() ends with scrollToLastNonEmptyLine(), which parks
|
||||
// the viewport ABOVE the bottom for any session whose buffer is taller than
|
||||
// the screen and ends in blank rows, i.e. every real session after a tab
|
||||
// switch. A tap-routing scheme that treats "viewport is scrolled up" as a
|
||||
// reason to blur strands document.activeElement on <body> with no way to
|
||||
// raise the keyboard, and the prompt row is no exception. Suppressing the
|
||||
// MOUSE REPORT while scrolled up is correct and pinned below; suppressing
|
||||
// FOCUS is not. Measured against PR #244 on 2026-08-09: body vs textarea.
|
||||
//
|
||||
// Must be a dispatched gesture: calling the touchend handler directly
|
||||
// bypasses touchstart's preventDefault, which is half of what closes the
|
||||
// focus path, so a direct call reports the right intent and still misses.
|
||||
it('keeps the terminal input focusable after a tab switch parks the viewport off-bottom', async () => {
|
||||
const probe = await page.evaluate(async () => {
|
||||
window.__sentInputs = [];
|
||||
app.activeSessionId = 'mobile-offbottom-tap-test';
|
||||
app.sessions.set('mobile-offbottom-tap-test', {
|
||||
id: 'mobile-offbottom-tap-test',
|
||||
mode: 'claude',
|
||||
cliVersion: '2.1.220',
|
||||
status: 'running',
|
||||
});
|
||||
app._sendInputAsync = (_sessionId: string, input: string) => {
|
||||
window.__sentInputs.push(input);
|
||||
};
|
||||
@@ -1006,41 +1058,29 @@ describe('Virtual Keyboard', () => {
|
||||
app.saveAppSettingsToStorage(settings);
|
||||
app._updateCjkInputState();
|
||||
app.terminal.reset();
|
||||
|
||||
// Taller than the viewport, ending in the trailing blank rows that make
|
||||
// scrollToLastNonEmptyLine() stop short of the bottom.
|
||||
const lines: string[] = [];
|
||||
for (let i = 1; i <= app.terminal.rows * 3; i++) lines.push(`Transcript row ${i}`);
|
||||
lines.push('', '❯ ', '', '');
|
||||
await new Promise<void>((resolve) => app.terminal.write(lines.join('\r\n'), resolve));
|
||||
|
||||
app.scrollToLastNonEmptyLine(); // what selectSession() does on every tab switch
|
||||
await new Promise<void>((resolve) =>
|
||||
app.terminal.write('Agent readback\r\n tap to collapse\r\n\r\n› ask', resolve)
|
||||
);
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
const screen = app.terminal.element?.querySelector('.xterm-screen');
|
||||
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
|
||||
const rect = screen?.getBoundingClientRect();
|
||||
if (!rect || !cell?.width || !cell?.height) return null;
|
||||
const buffer = app.terminal.buffer.active;
|
||||
return {
|
||||
x: rect.left + cell.width * 2,
|
||||
y: rect.top + cell.height * 5.5,
|
||||
atBottom: buffer.viewportY >= buffer.baseY,
|
||||
y: rect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5),
|
||||
};
|
||||
});
|
||||
expect(point).not.toBeNull();
|
||||
|
||||
expect(probe).not.toBeNull();
|
||||
// The guard only means anything if the viewport really did park off-bottom.
|
||||
expect(probe!.atBottom).toBe(false);
|
||||
|
||||
await page.touchscreen.tap(probe!.x, probe!.y);
|
||||
await page.touchscreen.tap(point!.x, point!.y);
|
||||
|
||||
const state = await page.evaluate(() => ({
|
||||
activeClass: document.activeElement?.className,
|
||||
sentInputs: window.__sentInputs,
|
||||
}));
|
||||
expect(state.activeClass).toContain('xterm-helper-textarea');
|
||||
// SGR coordinates are meaningless off-bottom, so the tap must stay silent.
|
||||
expect(state.sentInputs).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -543,25 +543,6 @@ describe('terminal touch tap mouse guard', () => {
|
||||
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('touch: forwards verified Claude transcript scrolling but keeps Codex touch in local history', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.terminal = {
|
||||
modes: { mouseTrackingMode: 'none' },
|
||||
buffer: { active: { viewportY: 50, baseY: 50 } },
|
||||
};
|
||||
|
||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]);
|
||||
expect(app._shouldForwardTouchScrollToApp()).toBe(true);
|
||||
|
||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
||||
expect(app._shouldForwardTouchScrollToApp()).toBe(false);
|
||||
|
||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
|
||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
||||
expect(app._shouldForwardTouchScrollToApp()).toBe(false);
|
||||
});
|
||||
|
||||
it('wheel: the local-scrollback opt-out pins the plain wheel to local scrollback (issue #154)', () => {
|
||||
const { app } = loadTerminalUiHarness();
|
||||
app.activeSessionId = 'sess-1';
|
||||
|
||||
Reference in New Issue
Block a user