diff --git a/.gitignore b/.gitignore index 2aec0d13..b9ca8c9c 100644 --- a/.gitignore +++ b/.gitignore @@ -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* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..37a36292 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# Repository Guidelines + +Canonical agent/contributor guidance for this repository lives in [CLAUDE.md](CLAUDE.md) — +project structure, build/test/lint commands, code style, testing safety rules +(never run the full suite inside a managed tmux session), security notes, and +the deployment workflow are all maintained there. Please read it before making +changes, and keep it the single source of truth rather than duplicating +sections here. + +Quick pointers: + +- Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check` +- Targeted tests only: `npm test -- test/.test.ts` (bare `npm test` is unsafe in managed sessions) +- Route tests use `app.inject()`; new tests needing ports must pick a unique `const PORT =` +- Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`) +- Never commit secrets or local state from `~/.codeman/` diff --git a/src/session.ts b/src/session.ts index cfc907d8..b50f22ac 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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; @@ -2050,15 +2052,51 @@ export class Session extends EventEmitter { private _ptyCols = 120; private _ptyRows = 40; + /** + * Live WebSocket connections that have announced a desktop viewport for this + * session. While at least one is registered, small-viewport (mobile/tablet) + * resizes are ignored so a phone glancing at the session can't reflow the + * PTY under an active desktop view. Claims are connection-scoped: ws-routes + * registers them on a desktop-typed resize and releases them on socket + * close, so a mobile-only session (no desktop connected) keeps full control + * of its own size — including narrowing below the spawn default. + * + * Deliberate tradeoff: claims are WS-only because only a socket has a + * liveness signal. A desktop degraded to the stateless HTTP resize fallback + * still applies its typed resizes but holds no claim, so a concurrent phone + * can reflow it. This is cooperative UX arbitration, not a security + * boundary — untyped (legacy/API) resizes bypass claims by design. + */ + private _desktopSizeClaims = new Set(); + + /** Register a live desktop sizing claim (see _desktopSizeClaims). */ + claimDesktopSizing(token: symbol): void { + this._desktopSizeClaims.add(token); + } + + /** Release a desktop sizing claim when its connection goes away. */ + releaseDesktopSizing(token: symbol): void { + this._desktopSizeClaims.delete(token); + } + /** * Resizes the PTY terminal dimensions. * Skips the resize if dimensions haven't changed to avoid triggering * unnecessary Ink full-screen redraws (visible flicker on tab switch). * + * Arbitration: while a desktop connection holds a sizing claim, resizes from + * small viewports (mobile/tablet) are ignored entirely — shrink AND grow + * would both reflow the desktop view. Without a desktop connected, small + * viewports control the PTY size freely. + * * @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 && this._desktopSizeClaims.size > 0) { + return; + } if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) { this._ptyCols = cols; this._ptyRows = rows; @@ -2156,6 +2194,12 @@ export class Session extends EventEmitter { this._clearAllTimers(); + // Drop desktop sizing claims defensively. Sockets normally release their + // own claim on close, but a hung client's close event can lag the session + // teardown by up to a ping cycle — don't let a stale claim suppress + // mobile resizes if this Session object sees any further use. + this._desktopSizeClaims.clear(); + // Immediately cleanup Promise callbacks to prevent orphaned references // during the rest of stop() processing (e.g., if mux kill times out) if (this.rejectPromise && !this._promptResolved) { diff --git a/src/web/public/app.js b/src/web/public/app.js index 821cbf2b..729ef861 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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(); @@ -1837,6 +1838,11 @@ class CodemanApp { if (this._ws === ws) { this._wsReady = true; this._wsReconnectAttempts = 0; + // Send a typed resize over the fresh socket: syncs PTY dims after + // (re)connects AND registers the desktop sizing claim server-side — + // selectSession's earlier resizes ran before this WS existed, so they + // went over HTTP, which never claims (see ws-routes sizingToken). + this.sendResize(sessionId)?.catch?.(() => {}); } }; @@ -2026,9 +2032,18 @@ 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?.() || {}; + // Mobile defaults ship cjkInputEnabled: false (native terminal input by + // default on touch), but an explicit user enable is honored everywhere — + // the App Settings toggle must not be a silent no-op on phones. + const showCjk = this._serverCjkOverride || (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 +2093,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 +2566,7 @@ class CodemanApp { const tallTabsEnabled = this._tallTabsEnabled ?? false; const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name; - parts.push(`