fix(mobile): address review blockers on the touch-device change set
Review follow-ups on PR #111 (rebased onto master post-#112/#113): Resize arbitration redesigned (review blocker 2): the previous 'cols < _ptyCols' guard froze a mobile-only session's PTY at the spawn default — narrow phones rendered clipped and could never re-fit. The guard now uses connection-scoped desktop sizing claims instead: ws-routes registers a claim on a desktop-typed resize and releases it on socket close (or when the same connection later reports a small viewport), and Session.resize() ignores mobile/tablet resizes only while at least one desktop connection holds a claim. A phone alone fully controls its size (shrink, rows-only shrink, re-grow); a phone glancing at a desktop-driven session can no longer reflow it. mobile-handlers' keyboard open/close resize now declares its viewport type so it participates in arbitration. Tests rewritten to cover mobile-only shrink/rows-only/re-grow, claim/release lifecycle, multi- claim behavior, and untyped legacy resizes; ws-routes test covers the claim lifecycle over a real socket. Solo/detached header restored (review blocker 3): index.html had removed #soloSessionTitle and #soloRedockBtn, which _applySoloMode still references — every detached window hit a null deref. Both are back alongside the new mobile utility toggle. Desktop leak fixed (review should-fix): .mobile-header-utility-toggle had no rule outside the <=768px media queries, so the raw button rendered on desktop. styles.css now hides it by default; the mobile/ tablet queries re-enable it. Visual-regression baselines reverted to master (review should-fix): the 18 contributor-machine PNGs are environment-specific (8 of the behavioral tests already report environment-sensitive failures across machines); re-baseline deliberately on the canonical machine instead. The 24 behavioral keyboard/layout/tabs tests are kept as-is. AGENTS.md trimmed to a pointer at CLAUDE.md (review should-fix) to avoid drift between duplicated guidance. Also dropped a dead getAttachmentHistoryForPersist stub (codex-branch residue — no such method exists in src/). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -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/<file>.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/`
|
||||
|
||||
@@ -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<symbol>();
|
||||
|
||||
/** 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)) {
|
||||
|
||||
@@ -77,7 +77,11 @@
|
||||
<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 mobile-collapsed" id="headerRight">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 394 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 115 KiB |
@@ -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 () => {});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -81,10 +81,6 @@ vi.mock('../src/session.js', () => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getAttachmentHistoryForPersist() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getOutput() {
|
||||
return 'mock output';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||