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
+4
View File
@@ -18,6 +18,10 @@ coverage/
test/e2e/screenshots/current/
test/e2e/screenshots/diffs/
# Mobile visual regression failure artifacts
test/mobile/snapshots/*.actual.png
test/mobile/snapshots/*.diff.png
# Logs
*.log
npm-debug.log*
+42
View File
@@ -0,0 +1,42 @@
# Repository Guidelines
## Project Structure & Module Organization
Codeman is a TypeScript ESM Node project. Core backend code lives in `src/`, with web routes in `src/web/routes/`, web server wiring in `src/web/`, config in `src/config/`, shared types in `src/types/`, and utilities in `src/utils/`. Browser assets are plain JavaScript/CSS under `src/web/public/`. Tests live in `test/`, with route tests in `test/routes/`, mobile Playwright/Vitest tests in `test/mobile/`, and mocks in `test/mocks/`. The standalone xterm local-echo package lives in `packages/xterm-zerolag-input/`; behavior copied into the web UI must stay in sync. Documentation and plans are in `docs/`, scripts in `scripts/`, and build/lint/test config in `config/`.
## Build, Test, and Development Commands
- `npm run dev`: start the local web app via `tsx src/index.ts web`.
- `npm run build`: build production output into `dist/` using `scripts/build.mjs`.
- `npm run start`: run the built CLI/web app from `dist/index.js`.
- `npm run typecheck`: run `tsc --noEmit`.
- `npm run lint` / `npm run lint:fix`: check or fix TypeScript lint issues.
- `npm run format:check` / `npm run format`: check or apply Prettier formatting.
- `npm run check:lockfile`: verify workspace lockfile sync.
## Coding Style & Naming Conventions
Use TypeScript with strict compiler settings and ESM imports only; avoid `require()`. Prettier uses single quotes, trailing commas where valid, and a 120-column print width. ESLint allows `console`, errors on `debugger`, and warns on explicit `any`. Prefer domain-specific files such as `src/web/routes/*-routes.ts`, `src/types/*.ts`, and `src/config/*.ts`; import config from specific files rather than barrels. Keep route handlers schema-validated with Zod where request input is accepted.
## Testing Guidelines
Vitest is the primary test framework. Do not run bare `npm test` in managed Codeman/tmux sessions because the full suite can spawn and clean up tmux sessions. Prefer targeted runs:
```bash
npm test -- test/routes/session-routes.test.ts
npm test -- -t "auth"
```
Route tests should use `app.inject()` instead of live ports. When adding tests that need ports, search for existing `const PORT =` values and choose a unique one. Coverage is available with `npm run test:coverage`.
## Commit & Pull Request Guidelines
Recent history uses Conventional Commit-style messages such as `feat(web): ...`, `fix: ...`, `docs: ...`, and `chore: version packages`. Keep commits scoped and descriptive. Pull requests should include the problem, the approach, linked issues when available, and screenshots or screen recordings for UI/mobile changes. Before opening a PR, run `npm run typecheck`, `npm run lint`, `npm run format:check`, `npm run check:lockfile`, and relevant targeted tests.
## Branch Workflow
Do not develop new features or bug fixes directly on `master`. Start from an up-to-date `master`, create a focused branch such as `fix/mobile-toolbar-overlap` or `feat/session-search`, commit there, then merge or open a pull request after verification. Keep uncommitted work on the branch until it is ready to integrate.
## Security & Configuration Tips
Do not commit secrets or local state from `~/.codeman/`. Auth and CLI behavior are controlled through environment variables such as `CODEMAN_USERNAME`, `CODEMAN_PASSWORD`, `CLAUDE_CODE_*`, and `OPENCODE_*`; validate new settings through the existing schemas and prefix allowlists.
+7 -1
View File
@@ -82,6 +82,8 @@ import { SessionTaskCache } from './session-task-cache.js';
export type { BackgroundTask } from './task-tracker.js';
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
export type ResizeViewportType = 'mobile' | 'tablet' | 'desktop';
/** Line buffer flush interval (100ms) - forces processing of partial lines */
const LINE_BUFFER_FLUSH_INTERVAL = 100;
@@ -2058,7 +2060,11 @@ export class Session extends EventEmitter {
* @param cols - Number of columns (width in characters)
* @param rows - Number of rows (height in lines)
*/
resize(cols: number, rows: number): void {
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void {
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
if (isSmallViewport && cols < this._ptyCols) {
return;
}
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
this._ptyCols = cols;
this._ptyRows = rows;
+39 -4
View File
@@ -576,6 +576,7 @@ class CodemanApp {
// Apply keyboard bar mode from settings
const _kbSettings = this.loadAppSettingsFromStorage();
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
this.bindMobileHeaderUtilityToggle?.();
this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
@@ -2026,9 +2027,20 @@ class CodemanApp {
const cjkEl = document.getElementById('cjkInput');
if (!cjkEl) return;
const settings = this.loadAppSettingsFromStorage();
const showCjk = this._serverCjkOverride || settings.cjkInputEnabled || false;
const defaults = this.getDefaultSettings?.() || {};
const isTouchTerminal =
typeof MobileDetection !== 'undefined' &&
MobileDetection.isTouchDevice() &&
(MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen());
const showCjk =
this._serverCjkOverride || (!isTouchTerminal && (settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false));
cjkEl.classList.toggle('cjk-input-visible', !!showCjk);
document.body.classList.toggle('cjk-input-visible', !!showCjk);
cjkEl.style.display = showCjk ? 'block' : 'none';
cjkEl.setAttribute('aria-hidden', showCjk ? 'false' : 'true');
if (showCjk && cjkEl.value === '\u200B') cjkEl.value = '';
if (!showCjk) window.cjkActive = false;
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.updateLayoutForKeyboard();
}
/**
@@ -2078,6 +2090,7 @@ class CodemanApp {
KeyboardHandler.cleanup();
MobileDetection.init();
KeyboardHandler.init();
this.bindMobileHeaderUtilityToggle?.();
// Clear tab alerts
this.tabAlerts.clear();
// Clear shown completions (used for duplicate notification prevention)
@@ -2550,7 +2563,7 @@ class CodemanApp {
const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.selectSession('${escapeHtml(id)}', { forceReload: true })" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, '${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -2631,6 +2644,15 @@ class CodemanApp {
container.addEventListener('keydown', this._tabKeydownHandler);
}
handleSessionTabClick(event, sessionId) {
event?.preventDefault?.();
const preserveKeyboard = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible === true;
if (!preserveKeyboard && MobileDetection.isTouchDevice()) {
document.activeElement?.blur?.();
}
return this.selectSession(sessionId, { forceReload: true, preserveKeyboard });
}
// ═══════════════════════════════════════════════════════════════
// Tab Order and Drag-and-Drop
@@ -2901,6 +2923,13 @@ class CodemanApp {
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
}
_shouldFocusTerminalForTabSwitch() {
if (typeof MobileDetection === 'undefined' || !MobileDetection.isTouchDevice()) {
return true;
}
return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
}
async selectSession(sessionId, options = {}) {
// If this session is popped out into its own window, raise that window
// instead of showing it inline (focus-on-click for detached tabs). If we
@@ -2925,7 +2954,13 @@ class CodemanApp {
// programmatic focus() within the user-gesture call stack (e.g. tab click).
// After the first await the gesture context is lost and focus() is silently
// ignored, leaving the keyboard unable to send input to the terminal.
if (this.terminal) this.terminal.focus();
const shouldFocusTerminal =
options?.preserveKeyboard === true
? this._shouldFocusTerminalForTabSwitch()
: options?.preserveKeyboard === false
? false
: this._shouldFocusTerminalForTabSwitch();
if (shouldFocusTerminal && this.terminal) this.terminal.focus();
const _selStart = performance.now();
const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8);
@@ -3231,7 +3266,7 @@ class CodemanApp {
this._connectWs(sessionId);
_crashDiag.log('FOCUS');
this.terminal.focus();
if (shouldFocusTerminal && this.terminal) this.terminal.focus();
this.scrollToLastNonEmptyLine();
this._clearTerminalLoadState(sessionId, selectGen);
_crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
+5 -5
View File
@@ -69,15 +69,15 @@
<span class="logo" onclick="app.goHome()" title="Go to main page">Codeman</span>
</div>
<button class="mobile-header-utility-toggle" id="mobileHeaderUtilityToggle" type="button" aria-label="Toggle header utilities" aria-controls="headerRight" aria-expanded="false" title="Header utilities">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</button>
<!-- Session Tabs -->
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
</div>
<!-- Detached single-session window title (shown only in solo mode) -->
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
<div class="header-right">
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">&#x229E;</button>
<div class="header-right mobile-collapsed" id="headerRight">
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
<span class="tunnel-dot"></span>
</button>
+70 -5
View File
@@ -41,6 +41,8 @@
// eslint-disable-next-line no-unused-vars
const CjkInput = (() => {
let _textarea = null;
let _terminalContainer = null;
let _xtermTextarea = null;
let _send = null;
let _initialized = false;
let _composing = false;
@@ -75,6 +77,23 @@ const CjkInput = (() => {
_textarea.setSelectionRange(1, 1);
}
function _isMobileComposer() {
return !!(
_textarea &&
typeof MobileDetection !== 'undefined' &&
MobileDetection.isTouchDevice() &&
_textarea.classList.contains('cjk-input-visible')
);
}
function _resetInput() {
if (_isMobileComposer()) {
_textarea.value = '';
} else {
_resetToPhantom();
}
}
/** Check if textarea contains only phantom(s) or is empty — no real user text */
function _isEffectivelyEmpty() {
return !_strip(_textarea.value);
@@ -97,24 +116,44 @@ const CjkInput = (() => {
_composing = false;
_textarea = document.getElementById('cjkInput');
if (!_textarea) return this;
_terminalContainer = document.getElementById('terminalContainer');
// Seed the phantom character
_resetToPhantom();
// Seed the phantom character for the hidden/immediate CJK path.
_resetInput();
_listeners.mousedown = (e) => { e.stopPropagation(); };
_listeners.focus = () => {
window.cjkActive = true;
if (_isMobileComposer() && _textarea.value === PHANTOM) {
_textarea.value = '';
return;
}
// Restore phantom if textarea was emptied while blurred
if (!_textarea.value) _resetToPhantom();
if (!_textarea.value && !_isMobileComposer()) _resetToPhantom();
};
_listeners.blur = () => { window.cjkActive = false; };
_textarea.addEventListener('mousedown', _listeners.mousedown);
_textarea.addEventListener('focus', _listeners.focus);
_textarea.addEventListener('blur', _listeners.blur);
_listeners.xtermFocusRedirect = () => {
if (!_isMobileComposer()) return;
_textarea.focus();
};
if (_terminalContainer) {
_xtermTextarea = _terminalContainer.querySelector('.xterm-helper-textarea');
if (_xtermTextarea) {
_xtermTextarea.addEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
}
}
// ── Composition tracking ──
_listeners.compositionstart = () => {
_composing = true;
if (_isMobileComposer()) {
if (_textarea.value === PHANTOM) _textarea.value = '';
return;
}
// Clear phantom so IME sees a clean textarea — some IMEs include
// existing text in the composition region which would corrupt input.
if (_textarea.value === PHANTOM) {
@@ -123,6 +162,7 @@ const CjkInput = (() => {
};
_listeners.compositionend = () => {
_composing = false;
if (_isMobileComposer()) return;
// Defer flush: some Android IMEs haven't committed text to textarea
// when compositionend fires. setTimeout(0) ensures we read the final value.
setTimeout(_flush, 0);
@@ -145,7 +185,7 @@ const CjkInput = (() => {
} else {
_send('\r');
}
_resetToPhantom();
_resetInput();
return;
}
@@ -153,7 +193,7 @@ const CjkInput = (() => {
if (e.key === 'Escape') {
e.preventDefault();
_composing = false;
_resetToPhantom();
_resetInput();
return;
}
@@ -167,6 +207,19 @@ const CjkInput = (() => {
// Below: only when NOT composing (composing keystrokes belong to IME)
if (_composing) return;
if (_isMobileComposer()) {
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
e.preventDefault();
_send('\x7f');
return;
}
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
e.preventDefault();
_send(PASSTHROUGH_KEYS[e.key]);
}
return;
}
// Backspace: forward to PTY when no real text in textarea
// (Desktop path — Android uses the input event + phantom approach)
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
@@ -198,6 +251,13 @@ const CjkInput = (() => {
// making keydown unreliable. input fires AFTER character insertion and
// carries inputType which tells us whether the text is final or tentative.
_listeners.input = (e) => {
if (_isMobileComposer()) {
if (_textarea.value.includes(PHANTOM)) {
_textarea.value = _strip(_textarea.value);
}
return;
}
// ── Backspace / delete detection ──
// Android long-press backspace generates rapid deleteContentBackward events.
// The phantom character ensures the textarea is never truly empty, so each
@@ -245,8 +305,13 @@ const CjkInput = (() => {
if (handler) _textarea.removeEventListener(event, handler);
}
}
if (_xtermTextarea && _listeners.xtermFocusRedirect) {
_xtermTextarea.removeEventListener('focus', _listeners.xtermFocusRedirect, { capture: true });
}
window.cjkActive = false;
_composing = false;
_terminalContainer = null;
_xtermTextarea = null;
for (const key of Object.keys(_listeners)) delete _listeners[key];
_initialized = false;
},
+22 -9
View File
@@ -255,10 +255,9 @@ const KeyboardHandler = {
if (heightDiff > 150 && !this.keyboardVisible) {
this.keyboardVisible = true;
document.body.classList.add('keyboard-visible');
// Restore --app-height: MobileDetection's resize listener fires before ours
// and may have already shrunk it for the keyboard viewport change.
// Use initialViewportHeight (captured before keyboard opened).
document.documentElement.style.setProperty('--app-height', `${this.initialViewportHeight}px`);
// While the keyboard is open, size the app to the visual viewport so
// xterm's bottom row and cursor sit above the OS keyboard.
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
this.onKeyboardShow();
}
// Keyboard hidden (viewport grew back close to initial)
@@ -277,6 +276,8 @@ const KeyboardHandler = {
// state changes, orientation changes, and other viewport shifts
if (!this.keyboardVisible) {
this.initialViewportHeight = currentHeight;
} else {
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
}
this.updateLayoutForKeyboard();
@@ -295,6 +296,7 @@ const KeyboardHandler = {
const toolbar = document.querySelector('.toolbar');
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
const cjkInput = document.getElementById('cjkInput');
const main = document.querySelector('.main');
if (this.keyboardVisible) {
@@ -302,7 +304,8 @@ const KeyboardHandler = {
// translate up so it sits at the bottom of the visual viewport.
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
// when the user types in xterm's hidden textarea.
const layoutHeight = window.innerHeight;
const appEl = document.querySelector('.app');
const layoutHeight = appEl?.getBoundingClientRect().bottom || window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
@@ -317,13 +320,17 @@ const KeyboardHandler = {
if (accessoryBar) {
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (cjkInput?.classList.contains('cjk-input-visible')) {
cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
// Shrink main content area so terminal doesn't extend behind keyboard.
// Use stable keyboard height (not scroll-dependent) for padding.
// 84px = toolbar (40px) + accessory bar (44px).
// Reserve only Codeman's visible controls. The OS keyboard is outside
// the visual viewport; adding its height here creates a large blank area
// above the mobile toolbar on iPhone.
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
if (main && keyboardHeight > 0) {
main.style.paddingBottom = `${keyboardHeight + 84}px`;
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
}
} else {
this.resetLayout();
@@ -334,6 +341,7 @@ const KeyboardHandler = {
resetLayout() {
const toolbar = document.querySelector('.toolbar');
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
const cjkInput = document.getElementById('cjkInput');
const main = document.querySelector('.main');
if (toolbar) {
@@ -342,6 +350,9 @@ const KeyboardHandler = {
if (accessoryBar) {
accessoryBar.style.transform = '';
}
if (cjkInput) {
cjkInput.style.transform = '';
}
if (main) {
main.style.paddingBottom = '';
}
@@ -376,6 +387,8 @@ const KeyboardHandler = {
// 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();
}
+157 -9
View File
@@ -55,7 +55,9 @@ html.mobile-init .file-browser-panel {
padding-right: calc(0.5rem + var(--safe-area-right));
background: #0a0a0a;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
z-index: 200;
contain: none;
overflow: visible;
z-index: 1200;
}
/* iOS safe area adjustment for fixed header - header extends into notch area */
@@ -92,8 +94,60 @@ html.mobile-init .file-browser-panel {
gap: 0.25rem;
}
.mobile-header-utility-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
margin: -4px 0.2rem -4px 0;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-muted);
order: -1;
position: relative;
z-index: 2;
flex-shrink: 0;
}
.mobile-header-utility-toggle.active,
.mobile-header-utility-toggle:active {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.header-right {
position: fixed;
top: calc(52px + var(--safe-area-top));
left: calc(0.5rem + var(--safe-area-left));
right: auto;
display: flex;
align-items: center;
gap: 0.35rem;
max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right));
padding: 0.35rem;
background: rgba(10, 10, 10, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
overflow-x: auto;
scrollbar-width: none;
z-index: 2000;
}
.header-right::-webkit-scrollbar {
display: none;
}
.header-right.mobile-collapsed {
display: none;
}
.btn-icon-header:not(.btn-sm) {
width: 44px;
height: 44px;
}
/* Compact session tabs — .tabs-two-rows override needed to match
@@ -355,7 +409,8 @@ html.mobile-init .file-browser-panel {
overflow: hidden;
background: #0a0a0a;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
z-index: 200;
contain: none;
z-index: 1200;
}
/* iOS safe area adjustment for fixed header - header extends into notch area */
@@ -391,8 +446,49 @@ html.mobile-init .file-browser-panel {
}
.header-right {
position: fixed;
top: calc(40px + var(--safe-area-top));
left: calc(0.3rem + var(--safe-area-left));
right: auto;
display: flex;
align-items: center;
padding-left: 0.2rem;
gap: 0.1rem;
max-width: calc(100vw - 0.6rem - var(--safe-area-left) - var(--safe-area-right));
padding: 0.25rem;
background: rgba(10, 10, 10, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
overflow-x: auto;
scrollbar-width: none;
border-left: none;
z-index: 2000;
}
.header-right::-webkit-scrollbar {
display: none;
}
.header-right.mobile-collapsed {
display: none;
}
.mobile-header-utility-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
margin: -6px 0.15rem -6px 0;
background: transparent;
border: none;
border-radius: 5px;
color: var(--text-muted);
order: -1;
position: relative;
z-index: 2;
flex-shrink: 0;
border-left: none;
}
@@ -445,7 +541,18 @@ html.mobile-init .file-browser-panel {
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.6);
color: #ef4444;
animation: voice-pulse 1.2s ease-in-out infinite;
animation: mobile-voice-pulse 1.2s ease-in-out infinite;
}
@keyframes mobile-voice-pulse {
0%, 100% {
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.2);
}
50% {
box-shadow: inset 0 0 0 2px rgba(239, 68, 68, 0.75);
background: rgba(239, 68, 68, 0.35);
}
}
/* Mobile app settings gear in toolbar - far right */
@@ -491,7 +598,8 @@ html.mobile-init .file-browser-panel {
top: 0;
left: 0;
right: 0;
bottom: 0;
bottom: auto;
height: var(--app-height, 100dvh);
}
/* Ultra-compact session tabs — .tabs-two-rows override needed to match
@@ -548,10 +656,10 @@ html.mobile-init .file-browser-panel {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.5rem;
font-size: 0.65rem;
line-height: 1;
width: 12px;
height: 12px;
width: 32px;
height: 32px;
margin-left: auto;
opacity: 0.6;
}
@@ -617,6 +725,10 @@ html.mobile-init .file-browser-panel {
align-items: center;
}
.toolbar-center .btn-toolbar.btn-voice {
display: none !important;
}
.toolbar-right {
display: none !important;
}
@@ -798,9 +910,17 @@ html.mobile-init .file-browser-panel {
.btn-toolbar.btn-shell {
flex: 0 0 auto;
min-width: fit-content;
min-width: 54px;
width: 54px;
white-space: nowrap;
padding: 0 10px !important;
padding: 0 8px !important;
overflow: hidden;
font-size: 0 !important;
}
.btn-toolbar.btn-shell::after {
content: "Shell";
font-size: 0.65rem;
}
/* Mobile case button - visible on mobile */
@@ -843,6 +963,24 @@ html.mobile-init .file-browser-panel {
color: #fff;
}
@media (max-width: 374px) {
.toolbar {
padding: 0 2px;
}
.toolbar-left,
.toolbar-left .toolbar-group,
.toolbar-left .toolbar-group:first-child {
gap: 2px;
}
}
@media (max-width: 430px) {
.btn-case-settings-mobile {
display: none !important;
}
}
/* Mobile case settings popover */
.case-settings-popover-mobile {
position: fixed;
@@ -1243,6 +1381,16 @@ html.mobile-init .file-browser-panel {
font-size: 0.85rem;
}
.history-show-more {
display: block;
margin-bottom: 0.75rem;
}
.welcome-ralph-link {
display: block;
margin: 0.75rem auto 0;
}
.welcome-hint {
font-size: 0.7rem;
margin-top: 0.75rem;
+70 -4
View File
@@ -326,7 +326,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false;
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
// Claude CLI settings
@@ -1603,6 +1603,7 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: false,
ralphTrackerEnabled: false,
tabTwoRows: false,
cjkInputEnabled: false,
};
}
// Desktop defaults - rely on ?? operators in apply functions
@@ -1643,9 +1644,10 @@ Object.assign(CodemanApp.prototype, {
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const showFontControls = settings.showFontControls ?? defaults.showFontControls ?? false;
const showSystemStats = settings.showSystemStats ?? defaults.showSystemStats ?? true;
const showTokenCount = settings.showTokenCount ?? defaults.showTokenCount ?? true;
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true);
const showTokenCount = compactHeader ? false : (settings.showTokenCount ?? defaults.showTokenCount ?? true);
const fontControlsEl = document.querySelector('.header-font-controls');
const systemStatsEl = document.getElementById('headerSystemStats');
@@ -1692,6 +1694,70 @@ Object.assign(CodemanApp.prototype, {
}
},
toggleMobileHeaderUtilities() {
const tray = document.getElementById('headerRight');
const toggle = document.getElementById('mobileHeaderUtilityToggle');
if (!tray) return;
const expanded = tray.classList.toggle('mobile-collapsed') === false;
if (toggle) {
toggle.classList.toggle('active', expanded);
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
},
handleMobileHeaderUtilityToggle(event) {
if (event) {
event.preventDefault?.();
event.stopPropagation?.();
const now = Date.now();
if ((event.type === 'click' || event.type === 'touchend') && this._lastMobileHeaderUtilityPointerAt) {
if (now - this._lastMobileHeaderUtilityPointerAt < 500) return;
}
if (event.type === 'click' && this._lastMobileHeaderUtilityTouchAt) {
if (now - this._lastMobileHeaderUtilityTouchAt < 500) return;
}
if (event.type === 'touchend') {
this._lastMobileHeaderUtilityTouchAt = now;
}
if (event.type === 'pointerup') {
this._lastMobileHeaderUtilityPointerAt = now;
}
}
this.toggleMobileHeaderUtilities();
},
bindMobileHeaderUtilityToggle() {
const toggle = document.getElementById('mobileHeaderUtilityToggle');
if (!toggle || this._mobileHeaderUtilityToggleEl === toggle) return;
if (this._mobileHeaderUtilityToggleEl && this._mobileHeaderUtilityToggleHandler) {
this._mobileHeaderUtilityToggleEl.removeEventListener('click', this._mobileHeaderUtilityToggleHandler);
this._mobileHeaderUtilityToggleEl.removeEventListener('touchend', this._mobileHeaderUtilityToggleHandler);
this._mobileHeaderUtilityToggleEl.removeEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
}
this._mobileHeaderUtilityToggleEl = toggle;
this._mobileHeaderUtilityToggleHandler = (event) => this.handleMobileHeaderUtilityToggle(event);
toggle.addEventListener('click', this._mobileHeaderUtilityToggleHandler);
toggle.addEventListener('touchend', this._mobileHeaderUtilityToggleHandler, { passive: false });
toggle.addEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
},
closeMobileHeaderUtilities() {
const tray = document.getElementById('headerRight');
const toggle = document.getElementById('mobileHeaderUtilityToggle');
if (!tray || tray.classList.contains('mobile-collapsed')) return;
tray.classList.add('mobile-collapsed');
if (toggle) {
toggle.classList.remove('active');
toggle.setAttribute('aria-expanded', 'false');
}
},
applyTabWrapSettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
+30 -3
View File
@@ -107,11 +107,11 @@ textarea:focus-visible {
caret-color: transparent !important;
}
.touch-device .xterm .xterm-helper-textarea {
left: 0 !important;
top: 0 !important;
left: var(--xterm-helper-left, 0px) !important;
top: var(--xterm-helper-top, 0px) !important;
width: 1px !important;
height: 1px !important;
z-index: -1 !important;
z-index: 0 !important;
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
}
@@ -8674,6 +8674,33 @@ kbd {
font-size: 12px;
}
.touch-device #cjkInput.cjk-input-visible {
position: fixed;
left: var(--safe-area-left);
right: var(--safe-area-right);
bottom: calc(var(--safe-area-bottom) + 40px);
z-index: 52;
display: block;
min-height: 44px;
max-height: 96px;
border: 1px solid rgba(80, 120, 190, 0.55);
border-left: none;
border-right: none;
background: #101827;
color: #f3f4f6;
box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.35);
transition: transform 0.15s ease-out;
will-change: transform;
}
.touch-device.keyboard-visible #cjkInput.cjk-input-visible {
bottom: calc(var(--safe-area-bottom) + 84px);
}
body.touch-device.cjk-input-visible .main {
padding-bottom: calc(84px + var(--safe-area-bottom));
}
/* ═══════════════════════════════════════════════════════════════
Orchestrator Panel
═══════════════════════════════════════════════════════════════ */
+109 -8
View File
@@ -262,15 +262,17 @@ Object.assign(CodemanApp.prototype, {
}
this._localEchoOverlay = new LocalEchoOverlay(this.terminal);
if (MobileDetection.isTouchDevice()) {
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
}
// CJK IME input — textarea in index.html, just wire up send
this._cjkInput = null;
if (typeof CjkInput !== 'undefined') {
this._cjkInput = CjkInput.init({
send: (text) => {
if (this.activeSessionId) {
this._sendInputAsync(this.activeSessionId, text);
}
this._handleCjkInput(text);
},
});
}
@@ -393,7 +395,13 @@ Object.assign(CodemanApp.prototype, {
// routes back to the terminal. Without this, a tap on the terminal area
// consumes the touch event but xterm's textarea never regains focus.
if (!didScroll && this.terminal) {
this.terminal.focus();
const cjkInput = document.getElementById('cjkInput');
if (cjkInput?.classList.contains('cjk-input-visible')) {
cjkInput.focus();
} else {
this._syncMobileHelperTextareaToCursor();
this.terminal.focus();
}
}
},
{ passive: true }
@@ -1468,12 +1476,80 @@ Object.assign(CodemanApp.prototype, {
this._localEchoOverlay.clear();
this._localEchoEnabled = false;
} else {
// Claude Code: scan for ❯ prompt character
this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });
// Codex/Claude-style TUIs usually expose a ❯ prompt. During active
// redraws or compact mobile layouts that marker may not be present in
// the viewport, while xterm's cursor still marks the editable input
// position. Fall back to cursor coordinates so phone typing appears at
// the terminal cursor instead of disappearing into pending state.
this._localEchoOverlay.setPrompt({
type: 'custom',
offset: 0,
find: (terminal) => {
try {
const buf = terminal.buffer.active;
for (let row = terminal.rows - 1; row >= 0; row--) {
const line = buf.getLine(buf.viewportY + row);
if (!line) continue;
const text = line.translateToString(true);
const idx = text.lastIndexOf('\u276f');
if (idx >= 0) return { row, col: idx + 2 };
}
return {
row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)),
col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)),
};
} catch {
return null;
}
},
});
}
}
},
_handleCjkInput(text) {
if (!this.activeSessionId) return;
const sessionId = this.activeSessionId;
const session = this.sessions.get(sessionId);
const useLocalEcho = !!(this._localEchoEnabled && this._localEchoOverlay && session?.mode !== 'shell');
if (!useLocalEcho) {
this._sendInputAsync(sessionId, text);
return;
}
if (text === '\x7f') {
const source = this._localEchoOverlay.removeChar();
if (source === 'flushed') this._sendInputAsync(sessionId, text);
return;
}
if (/[\r\n]+$/.test(text)) {
const committed = text.replace(/[\r\n]+$/g, '');
if (committed) this._localEchoOverlay.appendText(committed);
const pending = this._localEchoOverlay.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
if (pending) this._sendInputAsync(sessionId, pending);
setTimeout(() => this._sendInputAsync(sessionId, '\r'), pending ? 80 : 0);
return;
}
if (text.length === 1 && text.charCodeAt(0) < 32) {
const pending = this._localEchoOverlay.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
if (pending) this._sendInputAsync(sessionId, pending);
this._sendInputAsync(sessionId, text);
return;
}
this._localEchoOverlay.appendText(text);
},
/**
* Flush pending writes to terminal, processing DEC 2026 sync markers.
* Strips markers and writes content atomically within a single frame.
@@ -1849,6 +1925,23 @@ Object.assign(CodemanApp.prototype, {
}
},
_syncMobileHelperTextareaToCursor() {
if (!MobileDetection.isTouchDevice() || !this.terminal?.element) return;
try {
const xtermEl = this.terminal.element;
const cursor = this.terminal.element.querySelector('.xterm-cursor');
const screen = this.terminal.element.querySelector('.xterm-screen');
if (!(xtermEl instanceof HTMLElement) || !(cursor instanceof HTMLElement) || !(screen instanceof HTMLElement)) return;
const cursorRect = cursor.getBoundingClientRect();
const screenRect = screen.getBoundingClientRect();
if (!cursorRect.width && !cursorRect.height) return;
const left = Math.max(0, Math.round(cursorRect.left - screenRect.left));
const top = Math.max(0, Math.round(cursorRect.top - screenRect.top));
xtermEl.style.setProperty('--xterm-helper-left', `${left}px`);
xtermEl.style.setProperty('--xterm-helper-top', `${top}px`);
} catch {}
},
increaseFontSize() {
const current = this.terminal.options.fontSize || 14;
this.setFontSize(Math.min(current + 2, 24));
@@ -1917,10 +2010,18 @@ Object.assign(CodemanApp.prototype, {
// clear the terminal for the same dimensions (which would blank the screen
// without a subsequent Ink redraw to repaint it).
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
const viewportType =
typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType
? MobileDetection.getDeviceType()
: window.innerWidth < 430
? 'mobile'
: window.innerWidth < 768
? 'tablet'
: 'desktop';
// Fast path: WebSocket resize
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
try {
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows }));
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: viewportType }));
return changed;
} catch {
// Fall through to HTTP POST
@@ -1929,7 +2030,7 @@ Object.assign(CodemanApp.prototype, {
await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dims),
body: JSON.stringify({ ...dims, viewportType }),
});
return changed;
},
+6 -2
View File
@@ -677,10 +677,14 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/resize', async (req) => {
const { id } = req.params as { id: string };
const { cols, rows } = parseBody(ResizeSchema, req.body);
const { cols, rows, viewportType } = parseBody(ResizeSchema, req.body);
const session = findSessionOrFail(ctx, id);
session.resize(cols, rows);
if (viewportType) {
session.resize(cols, rows, { viewportType });
} else {
session.resize(cols, rows);
}
return {};
});
+6 -1
View File
@@ -126,7 +126,12 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
msg.r >= 1 &&
msg.r <= 200
) {
session.resize(msg.c, msg.r);
const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined;
if (viewportType) {
session.resize(msg.c, msg.r, { viewportType });
} else {
session.resize(msg.c, msg.r);
}
}
} catch {
// Ignore malformed messages
+1
View File
@@ -161,6 +161,7 @@ export const RunPromptSchema = z.object({
export const ResizeSchema = z.object({
cols: z.number().int().min(1).max(500),
rows: z.number().int().min(1).max(200),
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
});
// ========== Case Routes ==========
+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);
});
});