diff --git a/AGENTS.md b/AGENTS.md index e772c0e7..37a36292 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,42 +1,16 @@ # Repository Guidelines -## Project Structure & Module Organization +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. -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/`. +Quick pointers: -## 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. +- 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 c7e9084b..5362f9e4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2052,17 +2052,43 @@ 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. + */ + 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, options: { viewportType?: ResizeViewportType } = {}): void { const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet'; - if (isSmallViewport && cols < this._ptyCols) { + if (isSmallViewport && this._desktopSizeClaims.size > 0) { return; } if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) { diff --git a/src/web/public/index.html b/src/web/public/index.html index a9087772..438845bb 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -77,7 +77,11 @@
+ + +
+ diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 827ed4c4..08f4c452 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -434,10 +434,13 @@ const KeyboardHandler = { const cols = Math.max(dims.cols, 40); const rows = Math.max(dims.rows, 10); app._lastResizeDims = { cols, rows }; + // Declare the viewport type so resize arbitration can ignore this + // while a desktop connection is sizing the same session. + const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile'; fetch(`/api/sessions/${app.activeSessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cols, rows }), + body: JSON.stringify({ cols, rows, viewportType }), }).catch(() => {}); } } catch {} diff --git a/src/web/public/styles.css b/src/web/public/styles.css index a253aedf..1b9198da 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -190,6 +190,12 @@ body { filter: brightness(1.1); } +/* Mobile-only header utility toggle — hidden by default; the mobile/tablet + media queries in mobile.css (max-width: 768px) re-enable it as flex. */ +.mobile-header-utility-toggle { + display: none; +} + /* Session Tabs */ .session-tabs { display: flex; diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts index c22931ef..b32e7fb2 100644 --- a/src/web/routes/ws-routes.ts +++ b/src/web/routes/ws-routes.ts @@ -109,6 +109,12 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost socket.send(`{"t":"o","d":${JSON.stringify(DEC_2026_START + data + DEC_2026_END)}}`); }; + // Per-connection desktop sizing claim — registered on the first + // desktop-typed resize and released on socket close, so Session.resize() + // can ignore small-viewport resizes only while a desktop is actually + // connected (see Session._desktopSizeClaims). + const sizingToken = Symbol('ws-desktop-sizing'); + // Attach message handler synchronously BEFORE any async work // (@fastify/websocket requirement to avoid dropped messages). socket.on('message', (raw) => { @@ -127,6 +133,13 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost msg.r <= 200 ) { const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined; + if (viewportType === 'desktop') { + session.claimDesktopSizing(sizingToken); + } else if (viewportType) { + // The connection's viewport can change (e.g. browser window + // narrowed past the tablet breakpoint) — drop a stale claim. + session.releaseDesktopSizing(sizingToken); + } if (viewportType) { session.resize(msg.c, msg.r, { viewportType }); } else { @@ -210,6 +223,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost session.off('clearTerminal', onClearTerminal); session.off('needsRefresh', onNeedsRefresh); session.off('exit', onSessionExit); + session.releaseDesktopSizing(sizingToken); // Decrement per-session connection count const count = sessionWsCount.get(id) ?? 1; diff --git a/test/mobile/snapshots/device-ipad-mini.png b/test/mobile/snapshots/device-ipad-mini.png index ff26fe40..1935f9ef 100644 Binary files a/test/mobile/snapshots/device-ipad-mini.png and b/test/mobile/snapshots/device-ipad-mini.png differ diff --git a/test/mobile/snapshots/device-ipad-pro-11.png b/test/mobile/snapshots/device-ipad-pro-11.png index 67f1e9c9..74c63eac 100644 Binary files a/test/mobile/snapshots/device-ipad-pro-11.png and b/test/mobile/snapshots/device-ipad-pro-11.png differ diff --git a/test/mobile/snapshots/device-iphone-14-pro.png b/test/mobile/snapshots/device-iphone-14-pro.png index eb0a2c9b..6192bfde 100644 Binary files a/test/mobile/snapshots/device-iphone-14-pro.png and b/test/mobile/snapshots/device-iphone-14-pro.png differ diff --git a/test/mobile/snapshots/device-iphone-15-pro-max.png b/test/mobile/snapshots/device-iphone-15-pro-max.png index 28c7fbb0..66b6fdba 100644 Binary files a/test/mobile/snapshots/device-iphone-15-pro-max.png and b/test/mobile/snapshots/device-iphone-15-pro-max.png differ diff --git a/test/mobile/snapshots/device-iphone-se.png b/test/mobile/snapshots/device-iphone-se.png index c1c93693..92993db2 100644 Binary files a/test/mobile/snapshots/device-iphone-se.png and b/test/mobile/snapshots/device-iphone-se.png differ diff --git a/test/mobile/snapshots/device-nexus-7.png b/test/mobile/snapshots/device-nexus-7.png index fa426bd6..a21d7d2e 100644 Binary files a/test/mobile/snapshots/device-nexus-7.png and b/test/mobile/snapshots/device-nexus-7.png differ diff --git a/test/mobile/snapshots/keyboard-1024w.png b/test/mobile/snapshots/keyboard-1024w.png index 1c51e8b5..cdb34785 100644 Binary files a/test/mobile/snapshots/keyboard-1024w.png and b/test/mobile/snapshots/keyboard-1024w.png differ diff --git a/test/mobile/snapshots/keyboard-320w.png b/test/mobile/snapshots/keyboard-320w.png index 23d32283..d279f4d0 100644 Binary files a/test/mobile/snapshots/keyboard-320w.png and b/test/mobile/snapshots/keyboard-320w.png differ diff --git a/test/mobile/snapshots/keyboard-375w.png b/test/mobile/snapshots/keyboard-375w.png index 287ac64d..f38c5e26 100644 Binary files a/test/mobile/snapshots/keyboard-375w.png and b/test/mobile/snapshots/keyboard-375w.png differ diff --git a/test/mobile/snapshots/keyboard-393w.png b/test/mobile/snapshots/keyboard-393w.png index 84045141..d2bc834e 100644 Binary files a/test/mobile/snapshots/keyboard-393w.png and b/test/mobile/snapshots/keyboard-393w.png differ diff --git a/test/mobile/snapshots/keyboard-430w.png b/test/mobile/snapshots/keyboard-430w.png index 6e53423e..a6b079dc 100644 Binary files a/test/mobile/snapshots/keyboard-430w.png and b/test/mobile/snapshots/keyboard-430w.png differ diff --git a/test/mobile/snapshots/keyboard-768w.png b/test/mobile/snapshots/keyboard-768w.png index 3f3f7bde..3860e176 100644 Binary files a/test/mobile/snapshots/keyboard-768w.png and b/test/mobile/snapshots/keyboard-768w.png differ diff --git a/test/mobile/snapshots/landing-1024w.png b/test/mobile/snapshots/landing-1024w.png index 1935a01e..c9cee544 100644 Binary files a/test/mobile/snapshots/landing-1024w.png and b/test/mobile/snapshots/landing-1024w.png differ diff --git a/test/mobile/snapshots/landing-320w.png b/test/mobile/snapshots/landing-320w.png index 2d02b8b5..7ea0e5da 100644 Binary files a/test/mobile/snapshots/landing-320w.png and b/test/mobile/snapshots/landing-320w.png differ diff --git a/test/mobile/snapshots/landing-375w.png b/test/mobile/snapshots/landing-375w.png index 0e989655..ea552cec 100644 Binary files a/test/mobile/snapshots/landing-375w.png and b/test/mobile/snapshots/landing-375w.png differ diff --git a/test/mobile/snapshots/landing-393w.png b/test/mobile/snapshots/landing-393w.png index e4263012..994213f4 100644 Binary files a/test/mobile/snapshots/landing-393w.png and b/test/mobile/snapshots/landing-393w.png differ diff --git a/test/mobile/snapshots/landing-430w.png b/test/mobile/snapshots/landing-430w.png index 91e25760..18c3a6a6 100644 Binary files a/test/mobile/snapshots/landing-430w.png and b/test/mobile/snapshots/landing-430w.png differ diff --git a/test/mobile/snapshots/landing-768w.png b/test/mobile/snapshots/landing-768w.png index 7e1e4973..768b4623 100644 Binary files a/test/mobile/snapshots/landing-768w.png and b/test/mobile/snapshots/landing-768w.png differ diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index cc03e1e5..3677cd9c 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -236,6 +236,10 @@ export class MockSession extends EventEmitter { /** Stub for resize */ resize = vi.fn(); + /** Stubs for the desktop sizing claims used by resize arbitration */ + claimDesktopSizing = vi.fn(); + releaseDesktopSizing = vi.fn(); + /** Stub for runPrompt */ runPrompt = vi.fn(async () => {}); diff --git a/test/routes/ws-routes.test.ts b/test/routes/ws-routes.test.ts index eac1e8df..342ae0aa 100644 --- a/test/routes/ws-routes.test.ts +++ b/test/routes/ws-routes.test.ts @@ -307,6 +307,33 @@ describe('ws-routes', () => { } }); + it('claims desktop sizing on a desktop resize and releases it on close', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + const session = ctx._session; + try { + ws.send(JSON.stringify({ t: 'z', c: 160, r: 48, v: 'desktop' })); + + await vi.waitFor(() => { + expect(session.claimDesktopSizing).toHaveBeenCalledTimes(1); + }); + const token = session.claimDesktopSizing.mock.calls[0][0]; + + // A later small-viewport resize on the SAME connection drops the claim + // (window narrowed past the breakpoint). + ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'tablet' })); + await vi.waitFor(() => { + expect(session.releaseDesktopSizing).toHaveBeenCalledWith(token); + }); + } finally { + ws.close(); + } + + // Socket close releases the claim again (idempotent set delete). + await vi.waitFor(() => { + expect(session.releaseDesktopSizing.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + }); + it('accepts resize at minimum bounds (1x1)', async () => { const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); try { diff --git a/test/session-manager.test.ts b/test/session-manager.test.ts index 46c0ee38..4fe6ca6f 100644 --- a/test/session-manager.test.ts +++ b/test/session-manager.test.ts @@ -81,10 +81,6 @@ vi.mock('../src/session.js', () => { return undefined; } - getAttachmentHistoryForPersist() { - return undefined; - } - getOutput() { return 'mock output'; } diff --git a/test/session-resize-arbitration.test.ts b/test/session-resize-arbitration.test.ts index 871950ad..dfa109ec 100644 --- a/test/session-resize-arbitration.test.ts +++ b/test/session-resize-arbitration.test.ts @@ -17,21 +17,96 @@ function attachFakePty(session: Session, cols = 160, rows = 48) { } describe('Session resize arbitration', () => { - it('ignores mobile resizes that would shrink a wider desktop PTY', () => { + it('lets a mobile-only session shrink below the spawn default (no desktop connected)', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell' }); const resize = attachFakePty(session, 160, 48); session.resize(48, 28, { viewportType: 'mobile' }); - expect(resize).not.toHaveBeenCalled(); + expect(resize).toHaveBeenCalledWith(48, 28); }); - it('allows desktop resizes to change the shared PTY dimensions', () => { + it('lets a mobile-only session shrink rows only', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell' }); const resize = attachFakePty(session, 160, 48); + session.resize(160, 28, { viewportType: 'mobile' }); + + expect(resize).toHaveBeenCalledWith(160, 28); + }); + + it('lets a mobile-only session re-grow after shrinking', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell' }); + const resize = attachFakePty(session, 160, 48); + + session.resize(48, 28, { viewportType: 'mobile' }); + session.resize(80, 36, { viewportType: 'tablet' }); + + expect(resize).toHaveBeenNthCalledWith(1, 48, 28); + expect(resize).toHaveBeenNthCalledWith(2, 80, 36); + }); + + it('ignores mobile resizes while a desktop connection holds a sizing claim', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell' }); + const resize = attachFakePty(session, 160, 48); + const desktop = Symbol('desktop-conn'); + + session.claimDesktopSizing(desktop); + session.resize(48, 28, { viewportType: 'mobile' }); + // Grow is ignored too — it would reflow the desktop view just the same. + session.resize(200, 60, { viewportType: 'tablet' }); + + expect(resize).not.toHaveBeenCalled(); + }); + + it('always applies desktop resizes, claim or not', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell' }); + const resize = attachFakePty(session, 160, 48); + + session.claimDesktopSizing(Symbol('desktop-conn')); session.resize(120, 40, { viewportType: 'desktop' }); expect(resize).toHaveBeenCalledWith(120, 40); }); + + it('restores mobile control once the desktop claim is released', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell' }); + const resize = attachFakePty(session, 160, 48); + const desktop = Symbol('desktop-conn'); + + session.claimDesktopSizing(desktop); + session.resize(48, 28, { viewportType: 'mobile' }); + expect(resize).not.toHaveBeenCalled(); + + session.releaseDesktopSizing(desktop); + session.resize(48, 28, { viewportType: 'mobile' }); + expect(resize).toHaveBeenCalledWith(48, 28); + }); + + it('keeps ignoring mobile resizes until every desktop claim is released', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell' }); + const resize = attachFakePty(session, 160, 48); + const desktopA = Symbol('desktop-a'); + const desktopB = Symbol('desktop-b'); + + session.claimDesktopSizing(desktopA); + session.claimDesktopSizing(desktopB); + session.releaseDesktopSizing(desktopA); + session.resize(48, 28, { viewportType: 'mobile' }); + expect(resize).not.toHaveBeenCalled(); + + session.releaseDesktopSizing(desktopB); + session.resize(48, 28, { viewportType: 'mobile' }); + expect(resize).toHaveBeenCalledWith(48, 28); + }); + + it('applies untyped (legacy/API) resizes regardless of claims', () => { + const session = new Session({ workingDir: '/tmp', mode: 'shell' }); + const resize = attachFakePty(session, 160, 48); + + session.claimDesktopSizing(Symbol('desktop-conn')); + session.resize(100, 30); + + expect(resize).toHaveBeenCalledWith(100, 30); + }); });