fix(mobile): terminal and layout fixes for touch devices

Mobile-focused fixes for the web UI: keyboard-accessory layout and
overlap, native input visibility above the keyboard, CJK input handling,
terminal touch scrolling, tab-menu tap targets, mic-recording glow
containment, and mobile resize/keyboard-state handling on tab switch,
plus mobile visual-regression test coverage and snapshots.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
This commit is contained in:
Aamer Akhter
2026-06-10 03:47:26 +02:00
committed by arkon
co-authored by Saqeb Akhter
parent c29475ed10
commit 02fa3f30f5
39 changed files with 1322 additions and 67 deletions
+370 -5
View File
@@ -222,16 +222,29 @@ describe('Virtual Keyboard', () => {
await context.close();
});
it('toolbar slides up via translateY on keyboard show', async () => {
it('toolbar remains below terminal when keyboard show shrinks the app viewport', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
const transform = await page.evaluate(() => {
const layout = await page.evaluate(() => {
const toolbar = document.querySelector('.toolbar') as HTMLElement | null;
return toolbar?.style.transform ?? '';
const accessory = document.querySelector('.keyboard-accessory-bar') as HTMLElement | null;
const terminalWrap = document.querySelector('.terminal-wrap') as HTMLElement | null;
const toolbarRect = toolbar?.getBoundingClientRect();
const accessoryRect = accessory?.getBoundingClientRect();
const terminalRect = terminalWrap?.getBoundingClientRect();
return {
toolbarTransform: toolbar?.style.transform ?? '',
accessoryTransform: (accessory as HTMLElement | null)?.style.transform ?? '',
toolbarTop: toolbarRect?.top ?? 0,
accessoryTop: accessoryRect?.top ?? 0,
terminalBottom: terminalRect?.bottom ?? 0,
};
});
expect(transform).not.toBe('');
expect(transform).toContain('translateY');
expect(layout.toolbarTransform).toBe('');
expect(layout.accessoryTransform).toBe('');
expect(layout.accessoryTop).toBeGreaterThanOrEqual(layout.terminalBottom - 4);
expect(layout.toolbarTop).toBeGreaterThan(layout.accessoryTop);
});
it('accessory bar gets .visible class', async () => {
@@ -263,6 +276,32 @@ describe('Virtual Keyboard', () => {
expect(newPx).toBeGreaterThan(initialPx);
});
it('does not reserve the keyboard height as visible terminal dead space', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
const layout = await page.evaluate(() => {
const main = document.querySelector('.main') as HTMLElement | null;
const appEl = document.querySelector('.app') as HTMLElement | null;
const terminalWrap = document.querySelector('.terminal-wrap') as HTMLElement | null;
const toolbar = document.querySelector('.toolbar') as HTMLElement | null;
const accessory = document.querySelector('.keyboard-accessory-bar') as HTMLElement | null;
return {
appHeight: appEl?.getBoundingClientRect().height ?? 0,
mainPaddingBottom: main ? parseFloat(main.style.paddingBottom || '0') : 0,
terminalHeight: terminalWrap?.getBoundingClientRect().height ?? 0,
toolbarHeight: toolbar?.getBoundingClientRect().height ?? 0,
accessoryHeight: accessory?.getBoundingClientRect().height ?? 0,
visualViewportHeight: window.visualViewport?.height ?? window.innerHeight,
};
});
expect(layout.appHeight).toBeLessThanOrEqual(layout.visualViewportHeight + 2);
expect(layout.mainPaddingBottom).toBeLessThan(KEYBOARD.TYPICAL_IOS_HEIGHT);
expect(layout.mainPaddingBottom).toBeGreaterThanOrEqual(layout.toolbarHeight + layout.accessoryHeight - 4);
expect(layout.terminalHeight).toBeGreaterThan(160);
});
it('resetLayout clears transforms on hide', async () => {
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
@@ -400,6 +439,332 @@ describe('Virtual Keyboard', () => {
// Soft assertion — fitAddon may not be initialized without real terminal
expect(callCount).toBeGreaterThanOrEqual(0);
});
it('keeps xterm helper textarea focusable near the terminal cursor on touch devices', async () => {
const styles = await page.evaluate(async () => {
await new Promise<void>((resolve) => app.terminal.write('prompt', resolve));
app.terminal.focus();
app._syncMobileHelperTextareaToCursor?.();
const textarea = document.querySelector('.xterm-helper-textarea');
const cursor = document.querySelector('.xterm-cursor');
const screen = document.querySelector('.xterm-screen');
if (!(textarea instanceof HTMLElement) || !(cursor instanceof HTMLElement) || !(screen instanceof HTMLElement))
return null;
const cs = getComputedStyle(textarea);
const cursorRect = cursor.getBoundingClientRect();
const screenRect = screen.getBoundingClientRect();
return {
left: cs.left,
top: cs.top,
width: cs.width,
height: cs.height,
zIndex: cs.zIndex,
opacity: cs.opacity,
cursorLeft: `${Math.max(0, Math.round(cursorRect.left - screenRect.left))}px`,
cursorTop: `${Math.max(0, Math.round(cursorRect.top - screenRect.top))}px`,
};
});
expect(styles).not.toBeNull();
expect(styles?.left).toBe(styles?.cursorLeft);
expect(styles?.top).toBe(styles?.cursorTop);
expect(styles?.cursorLeft).not.toBe('0px');
expect(styles?.width).toBe('1px');
expect(styles?.height).toBe('1px');
expect(styles?.opacity).toBe('0');
expect(Number(styles?.zIndex)).toBeGreaterThanOrEqual(0);
});
it('routes CJK textarea typing through local echo on Enter', async () => {
await page.evaluate(() => {
window.__sentInputs = [];
const sessionId = 'mobile-cjk-local-echo-test';
app.activeSessionId = sessionId;
app.sessions.set(sessionId, { id: sessionId, mode: 'codex' });
app._localEchoEnabled = true;
app._localEchoOverlay = {
pendingText: '',
appendText(text: string) {
this.pendingText += text;
},
removeChar() {
this.pendingText = this.pendingText.slice(0, -1);
return 'pending';
},
clear() {
this.pendingText = '';
},
suppressBufferDetection() {},
};
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = true;
app.saveAppSettingsToStorage(settings);
app._serverCjkOverride = true;
app._updateCjkInputState?.();
});
await page.locator('#cjkInput').focus();
await page.keyboard.type('hello');
const beforeEnter = await page.evaluate(() => ({
visibleText: (document.getElementById('cjkInput') as HTMLTextAreaElement).value.replace(/\u200B/g, ''),
pendingText: app._localEchoOverlay.pendingText,
sentInputs: window.__sentInputs,
}));
expect(beforeEnter.visibleText).toBe('hello');
expect(beforeEnter.pendingText).toBe('');
expect(beforeEnter.sentInputs).toEqual([]);
await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__sentInputs?.length === 2);
const afterEnter = await page.evaluate(() => ({
pendingText: app._localEchoOverlay.pendingText,
sentInputs: window.__sentInputs,
}));
expect(afterEnter.pendingText).toBe('');
expect(afterEnter.sentInputs).toEqual(['hello', '\r']);
});
it('shows the CJK textarea on mobile only for server override', async () => {
const state = await page.evaluate(() => {
app._serverCjkOverride = true;
app._updateCjkInputState();
const input = document.getElementById('cjkInput');
if (!(input instanceof HTMLElement)) return null;
const cs = getComputedStyle(input);
return {
display: cs.display,
position: cs.position,
bottom: cs.bottom,
zIndex: cs.zIndex,
ariaHidden: input.getAttribute('aria-hidden'),
};
});
expect(state).not.toBeNull();
expect(state?.display).not.toBe('none');
expect(state?.position).toBe('fixed');
expect(Number(state?.zIndex)).toBeGreaterThan(50);
expect(state?.ariaHidden).toBe('false');
});
it('hides the CJK textarea by default on phones', async () => {
const state = await page.evaluate(() => {
localStorage.removeItem(app.getSettingsStorageKey());
app._cachedAppSettings = null;
app._updateCjkInputState();
const input = document.getElementById('cjkInput');
if (!(input instanceof HTMLElement)) return null;
const cs = getComputedStyle(input);
return {
display: cs.display,
position: cs.position,
bodyClass: document.body.classList.contains('cjk-input-visible'),
};
});
expect(state).not.toBeNull();
expect(state?.display).toBe('none');
expect(state?.bodyClass).toBe(false);
});
it('keeps the CJK textarea hidden even when old phone settings enabled it', async () => {
const state = await page.evaluate(() => {
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = true;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
const input = document.getElementById('cjkInput');
if (!(input instanceof HTMLElement)) return null;
const cs = getComputedStyle(input);
return {
display: cs.display,
position: cs.position,
bodyClass: document.body.classList.contains('cjk-input-visible'),
};
});
expect(state).not.toBeNull();
expect(state?.display).toBe('none');
expect(state?.bodyClass).toBe(false);
});
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');
});
it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => {
const calls = await page.evaluate(async () => {
app.activeSessionId = 'mobile-touch-scroll-test';
app.sessions.set('mobile-touch-scroll-test', {
id: 'mobile-touch-scroll-test',
mode: 'codex',
status: 'running',
});
app.hideWelcome();
app._updateCjkInputState();
const originalScrollLines = app.terminal.scrollLines.bind(app.terminal);
const scrollCalls: number[] = [];
app.terminal.scrollLines = (lines: number) => {
scrollCalls.push(lines);
return originalScrollLines(lines);
};
const target =
document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer');
if (!target) return scrollCalls;
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const startY = rect.top + Math.min(180, rect.height - 20);
const endY = startY - 120;
function createTouch(y: number) {
return new Touch({
identifier: 1,
target,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
}
target.dispatchEvent(
new TouchEvent('touchstart', {
touches: [createTouch(startY)],
changedTouches: [createTouch(startY)],
bubbles: true,
cancelable: true,
})
);
target.dispatchEvent(
new TouchEvent('touchmove', {
touches: [createTouch(endY)],
changedTouches: [createTouch(endY)],
bubbles: true,
cancelable: true,
})
);
target.dispatchEvent(
new TouchEvent('touchend', {
touches: [],
changedTouches: [createTouch(endY)],
bubbles: true,
cancelable: true,
})
);
await new Promise((resolve) => setTimeout(resolve, 50));
return scrollCalls;
});
expect(calls.length).toBeGreaterThan(0);
expect(calls.some((lines) => lines !== 0)).toBe(true);
});
it('keeps typed phone text in the terminal local echo path', async () => {
await page.evaluate(() => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-visible-input-test';
app.sessions.set('mobile-visible-input-test', {
id: 'mobile-visible-input-test',
mode: 'codex',
status: 'running',
});
app.hideWelcome();
app._sendInputAsync = (_sessionId: string, input: string) => {
window.__sentInputs.push(input);
};
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = false;
settings.localEchoEnabled = true;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
app._updateLocalEchoState();
app.terminal.focus();
});
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
await page.keyboard.type('find bug');
const beforeEnter = await page.evaluate(() => ({
activeClass: document.activeElement?.className,
cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display,
pendingText: app._localEchoOverlay?.pendingText,
sentInputs: window.__sentInputs,
}));
expect(beforeEnter.activeClass).toContain('xterm-helper-textarea');
expect(beforeEnter.cjkDisplay).toBe('none');
expect(beforeEnter.pendingText).toBe('find bug');
expect(beforeEnter.sentInputs).toEqual([]);
await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__sentInputs?.join('') === 'find bug\r');
const afterEnter = await page.evaluate(() => ({
pendingText: app._localEchoOverlay?.pendingText,
sentInputs: window.__sentInputs,
}));
expect(afterEnter.pendingText).toBe('');
expect(afterEnter.sentInputs.join('')).toBe('find bug\r');
});
it('shows terminal local echo at the cursor when no prompt marker is visible', async () => {
await page.evaluate(async () => {
app.activeSessionId = 'mobile-cursor-fallback-test';
app.sessions.set('mobile-cursor-fallback-test', {
id: 'mobile-cursor-fallback-test',
mode: 'codex',
status: 'running',
});
app.hideWelcome();
const settings = app.loadAppSettingsFromStorage();
settings.cjkInputEnabled = false;
settings.localEchoEnabled = true;
app.saveAppSettingsToStorage(settings);
app._updateCjkInputState();
app._updateLocalEchoState();
app.terminal.reset();
await new Promise<void>((resolve) => app.terminal.write('working without prompt marker', resolve));
app.terminal.focus();
});
await page.keyboard.type('abc');
const state = await page.evaluate(() => ({
cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display,
pendingText: app._localEchoOverlay?.pendingText,
overlayState: app._localEchoOverlay?.state,
}));
expect(state.cjkDisplay).toBe('none');
expect(state.pendingText).toBe('abc');
expect(state.overlayState?.visible).toBe(true);
expect(state.overlayState?.promptPosition).not.toBeNull();
});
});
// ── Cross-device keyboard behavior ────────────────────────────────────
+141 -2
View File
@@ -151,6 +151,145 @@ describe('Mobile Layout', () => {
});
});
// ─── Toolbar Collision Regression ───────────────────────────────────────
describe('Toolbar Collision Regression', () => {
it('keeps phone toolbar controls inside the viewport', async () => {
const device = DEVICE_REGISTRY.find((d) => d.name === 'iPhone 8')!;
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
const layout = await page.evaluate(() => {
const buttons = [...document.querySelectorAll('.toolbar button')]
.filter((el): el is HTMLButtonElement => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
})
.map((el) => {
const rect = el.getBoundingClientRect();
return {
selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`,
left: rect.left,
right: rect.right,
};
});
return {
overflow: buttons.filter(({ left, right }) => left < 0 || right > window.innerWidth),
caseWidth: document.querySelector('.btn-case-mobile')?.getBoundingClientRect().width ?? 0,
};
});
expect(layout.overflow).toEqual([]);
expect(layout.caseWidth).toBeGreaterThanOrEqual(36);
} finally {
await context.close();
}
});
it('does not render the desktop voice button at the 430px phone/tablet boundary', async () => {
const device = REPRESENTATIVE_DEVICES['large-phone'];
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertHidden(page, '#voiceInputBtn');
await assertVisible(page, '#voiceInputBtnMobile');
} finally {
await context.close();
}
});
it('uses phone upload and voice controls without toolbar overlap', async () => {
const device = REPRESENTATIVE_DEVICES['small-phone'];
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
expect(await page.locator('.toolbar-center .btn-upload').isVisible()).toBe(false);
await assertVisible(page, '.btn-upload-mobile');
await assertVisible(page, '#voiceInputBtnMobile');
const violations = await page.evaluate(() => {
const visibleToolbarButtons = [...document.querySelectorAll('.toolbar button')]
.filter((el): el is HTMLButtonElement => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
})
.map((el) => ({
selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`,
rect: el.getBoundingClientRect(),
}));
const overlaps: string[] = [];
for (let i = 0; i < visibleToolbarButtons.length; i += 1) {
for (let j = i + 1; j < visibleToolbarButtons.length; j += 1) {
const a = visibleToolbarButtons[i];
const b = visibleToolbarButtons[j];
const intersects =
a.rect.left < b.rect.right &&
a.rect.right > b.rect.left &&
a.rect.top < b.rect.bottom &&
a.rect.bottom > b.rect.top;
if (intersects) {
overlaps.push(`${a.selector} overlaps ${b.selector}`);
}
}
}
return overlaps;
});
expect(violations).toEqual([]);
} finally {
await context.close();
}
});
it('keeps the mobile recording mic effect inside the button bounds', async () => {
const device = REPRESENTATIVE_DEVICES['small-phone'];
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await page.evaluate(() => {
document.getElementById('voiceInputBtnMobile')?.classList.add('recording');
});
const shadow = await page.evaluate(() => {
const button = document.getElementById('voiceInputBtnMobile');
return button ? getComputedStyle(button).boxShadow : '';
});
expect(shadow).toContain('inset');
expect(shadow).not.toContain(' 6px ');
} finally {
await context.close();
}
});
it('keeps desktop upload and voice controls in one centered row', async () => {
const { context, page } = await createDevicePage(iPadPro, BASE_URL, 'chromium');
try {
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertVisible(page, '.toolbar-center .btn-upload');
await assertVisible(page, '#voiceInputBtn');
const layout = await page.evaluate(() => {
const upload = document.querySelector('.toolbar-center .btn-upload')!.getBoundingClientRect();
const voice = document.querySelector('#voiceInputBtn')!.getBoundingClientRect();
return {
centerYDifference: Math.abs(upload.top + upload.height / 2 - (voice.top + voice.height / 2)),
centerXDifference: Math.abs(upload.left + upload.width / 2 - (voice.left + voice.width / 2)),
};
});
expect(layout.centerYDifference).toBeLessThanOrEqual(2);
expect(layout.centerXDifference).toBeGreaterThan(20);
} finally {
await context.close();
}
});
});
// ─── Device Classes ───────────────────────────────────────────────────────
describe('Device Classes', () => {
@@ -164,7 +303,7 @@ describe('Mobile Layout', () => {
});
it('Android user agent does NOT add ios-device', async () => {
const pixel = DEVICE_REGISTRY.find(d => d.name === 'Pixel 7')!;
const pixel = DEVICE_REGISTRY.find((d) => d.name === 'Pixel 7')!;
const { context, page } = await createDevicePage(pixel, BASE_URL);
try {
await assertNotHasClass(page, 'body', BODY_CLASSES.IOS);
@@ -273,7 +412,7 @@ describe('Mobile Layout', () => {
if (violations.length > 0) {
console.warn(
`Touch target violations (${violations.length}):\n` +
violations.map(v => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n'),
violations.map((v) => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n')
);
}
// Allow known small elements — notification action buttons (26x26px),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 193 KiB

+175 -9
View File
@@ -93,6 +93,122 @@ describe('Tab Navigation', () => {
expect(maxWidthPx).toBeGreaterThan(0);
}
});
it('active tab menu target is touch-sized and opens session options', async () => {
const hasActiveTab = await page.locator('.session-tab.active').count();
if (!hasActiveTab) {
await page.evaluate(() => {
const container = document.querySelector('.session-tabs');
if (!container) return;
container.innerHTML = `
<div class="session-tab active" data-id="mobile-menu-test">
<span class="tab-number">1</span>
<span class="tab-status idle"></span>
<span class="tab-info">
<span class="tab-name-row"><span class="tab-name">Session</span></span>
</span>
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('mobile-menu-test')" title="Session options" aria-label="Session options" tabindex="0">&#9881;</span>
<span class="tab-close">&times;</span>
</div>`;
(window as any).app.sessions.set('mobile-menu-test', {
id: 'mobile-menu-test',
name: 'Session',
status: 'idle',
mode: 'shell',
workingDir: '/tmp',
});
});
}
const gear = page.locator('.session-tab.active .tab-gear').first();
expect(await gear.isVisible()).toBe(true);
const box = await gear.boundingBox();
expect(box?.width ?? 0).toBeGreaterThanOrEqual(32);
expect(box?.height ?? 0).toBeGreaterThanOrEqual(32);
await gear.click();
const modalClass = await page.locator('#sessionOptionsModal').getAttribute('class');
expect(modalClass).toMatch(/active/);
});
it('top-left mobile menu button opens the header utility tray', async () => {
await page.evaluate(() => {
document.querySelectorAll('.modal.active').forEach((modal) => modal.classList.remove('active'));
document.getElementById('headerRight')?.classList.add('mobile-collapsed');
const toggle = document.getElementById('mobileHeaderUtilityToggle');
toggle?.classList.remove('active');
toggle?.setAttribute('aria-expanded', 'false');
});
const topLeftElements = await page.evaluate(() => {
return document.elementsFromPoint(16, 16).map((el) => ({
tag: el.tagName,
id: el.id,
className: String(el.className),
closestButtonId: el.closest('button')?.id ?? '',
}));
});
expect(topLeftElements[0]?.closestButtonId).toBe('mobileHeaderUtilityToggle');
const toggleBox = await page.locator('#mobileHeaderUtilityToggle').boundingBox();
expect(toggleBox?.width ?? 0).toBeGreaterThanOrEqual(44);
expect(toggleBox?.height ?? 0).toBeGreaterThanOrEqual(44);
await page.touchscreen.tap(
(toggleBox?.x ?? 0) + (toggleBox?.width ?? 0) / 2,
(toggleBox?.y ?? 0) + (toggleBox?.height ?? 0) / 2
);
await page.waitForTimeout(150);
const trayClass = await page.locator('#headerRight').getAttribute('class');
const expanded = await page.locator('#mobileHeaderUtilityToggle').getAttribute('aria-expanded');
const trayBox = await page.locator('#headerRight').boundingBox();
const topTrayElement = await page.evaluate(() => {
const tray = document.getElementById('headerRight');
const rect = tray?.getBoundingClientRect();
if (!rect) return '';
return (
document
.elementsFromPoint(rect.left + Math.min(24, rect.width / 2), rect.top + Math.min(24, rect.height / 2))
.find((el) => el.id === 'headerRight' || el.closest?.('#headerRight'))
?.closest?.('#headerRight')?.id ?? ''
);
});
expect(trayClass).not.toMatch(/mobile-collapsed/);
expect(expanded).toBe('true');
expect(trayBox?.x ?? 9999).toBeLessThanOrEqual((toggleBox?.x ?? 0) + (toggleBox?.width ?? 0) + 8);
expect(topTrayElement).toBe('headerRight');
});
it('tabs remain visible on large phone and tablet headers', async () => {
for (const device of [REPRESENTATIVE_DEVICES['large-phone'], REPRESENTATIVE_DEVICES['small-tablet']]) {
const { context: deviceContext, page: devicePage } = await createDevicePage(device, BASE_URL, 'chromium');
try {
await devicePage.waitForTimeout(WAIT.PAGE_SETTLE);
await devicePage.evaluate(() => {
const container = document.querySelector('.session-tabs');
if (!container) return;
container.innerHTML = '';
for (let i = 1; i <= 3; i++) {
const tab = document.createElement('div');
tab.className = i === 1 ? 'session-tab active' : 'session-tab';
tab.innerHTML = `<span class="tab-status idle"></span><span class="tab-name">Session ${i}</span>`;
container.appendChild(tab);
}
});
const tabsWidth = await devicePage.evaluate(() => {
return document.querySelector('.session-tabs')?.clientWidth ?? 0;
});
expect(tabsWidth).toBeGreaterThanOrEqual(120);
} finally {
await deviceContext.close();
}
}
});
});
// ─── Swipe Navigation (CDP - Chromium) ───────────────────────────────────
@@ -134,7 +250,9 @@ describe('Tab Navigation', () => {
}
async function clearSwipeLog(): Promise<void> {
await page.evaluate(() => { (window as any).__swipeLog = []; });
await page.evaluate(() => {
(window as any).__swipeLog = [];
});
}
it('swipe left calls nextSession', async () => {
@@ -193,10 +311,12 @@ describe('Tab Navigation', () => {
const steps = 5;
for (let i = 1; i <= steps; i++) {
const progress = i / steps;
await dispatchTouchEvent(cdp, 'touchMove', [{
x: startX + (endX - startX) * progress,
y: startY + (endY - startY) * progress,
}]);
await dispatchTouchEvent(cdp, 'touchMove', [
{
x: startX + (endX - startX) * progress,
y: startY + (endY - startY) * progress,
},
]);
await page.waitForTimeout(20);
}
await dispatchTouchEvent(cdp, 'touchEnd', []);
@@ -451,6 +571,51 @@ describe('Tab Navigation', () => {
});
});
describe('Tab Touch Focus', () => {
it('switching tabs with the keyboard closed does not leave the terminal textarea focused', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
const result = await page.evaluate(() => {
if (typeof app === 'undefined') return { hasHandler: false };
const textarea = document.querySelector('.xterm-helper-textarea');
if (textarea) textarea.focus();
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.keyboardVisible = false;
let selected: string | null = null;
let selectedOptions: { preserveKeyboard?: boolean } | null = null;
const originalSelect = app.selectSession;
app.selectSession = function (id, options) {
selected = id;
selectedOptions = options || {};
return Promise.resolve();
};
const event = new Event('click', { bubbles: true, cancelable: true });
if (typeof app.handleSessionTabClick === 'function') {
app.handleSessionTabClick(event, 'mock-session-2');
}
const activeIsTextarea = document.activeElement === textarea;
app.selectSession = originalSelect;
return {
hasHandler: typeof app.handleSessionTabClick === 'function',
selected: selected,
preserveKeyboard: selectedOptions ? selectedOptions.preserveKeyboard : undefined,
activeIsTextarea: activeIsTextarea,
};
});
expect(result.hasHandler).toBe(true);
expect(result.selected).toBe('mock-session-2');
expect(result.preserveKeyboard).toBe(false);
expect(result.activeIsTextarea).toBe(false);
} finally {
await context.close();
}
});
});
// ─── Tab Close Button Visibility ─────────────────────────────────────────
describe('Tab Close Button Visibility', () => {
@@ -521,10 +686,11 @@ describe('Tab Navigation', () => {
const isActive = tab?.classList.contains('active') ?? false;
const style = getComputedStyle(gear);
// Gear hidden via display:none (mobile) or opacity:0 + width:0 (desktop)
const isVisible = style.display !== 'none'
&& style.visibility !== 'hidden'
&& parseFloat(style.opacity) > 0
&& parseFloat(style.width) > 0;
const isVisible =
style.display !== 'none' &&
style.visibility !== 'hidden' &&
parseFloat(style.opacity) > 0 &&
parseFloat(style.width) > 0;
return { isActive, isVisible };
});
});
+12
View File
@@ -315,6 +315,18 @@ describe('session-routes', () => {
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40);
});
it('passes viewport type through for resize arbitration', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 48, rows: 28, viewportType: 'mobile' },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
});
it('rejects cols exceeding max (500)', async () => {
const res = await harness.app.inject({
method: 'POST',
+15
View File
@@ -292,6 +292,21 @@ describe('ws-routes', () => {
}
});
it('passes viewport type through for resize arbitration', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' }));
await vi.waitFor(() => {
expect(session.resize).toHaveBeenCalledWith(48, 28, { viewportType: 'mobile' });
});
} finally {
ws.close();
}
});
it('accepts resize at minimum bounds (1x1)', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
+4
View File
@@ -81,6 +81,10 @@ vi.mock('../src/session.js', () => {
return undefined;
}
getAttachmentHistoryForPersist() {
return undefined;
}
getOutput() {
return 'mock output';
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
type ResizeableSessionInternals = {
ptyProcess: { resize: (cols: number, rows: number) => void };
_ptyCols: number;
_ptyRows: number;
};
function attachFakePty(session: Session, cols = 160, rows = 48) {
const resize = vi.fn();
const internals = session as unknown as ResizeableSessionInternals;
internals.ptyProcess = { resize };
internals._ptyCols = cols;
internals._ptyRows = rows;
return resize;
}
describe('Session resize arbitration', () => {
it('ignores mobile resizes that would shrink a wider desktop PTY', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
const resize = attachFakePty(session, 160, 48);
session.resize(48, 28, { viewportType: 'mobile' });
expect(resize).not.toHaveBeenCalled();
});
it('allows desktop resizes to change the shared PTY dimensions', () => {
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
const resize = attachFakePty(session, 160, 48);
session.resize(120, 40, { viewportType: 'desktop' });
expect(resize).toHaveBeenCalledWith(120, 40);
});
});