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>
@@ -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*
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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">⊞</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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 {};
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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 ────────────────────────────────────
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 193 KiB |
@@ -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">⚙</span>
|
||||
<span class="tab-close">×</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 };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -81,6 +81,10 @@ vi.mock('../src/session.js', () => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getAttachmentHistoryForPersist() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getOutput() {
|
||||
return 'mock output';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||