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>
This commit is contained in:
arkon
2026-06-10 04:07:26 +02:00
co-authored by Claude Opus 4.8
parent 02fa3f30f5
commit e77df131b8
28 changed files with 176 additions and 47 deletions
+12 -38
View File
@@ -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/`
+27 -1
View File
@@ -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)) {
+4
View File
@@ -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">&#x229E;</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>
+4 -1
View File
@@ -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 {}
+6
View File
@@ -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;
+14
View File
@@ -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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 115 KiB

+4
View File
@@ -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 () => {});
+27
View File
@@ -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 {
-4
View File
@@ -81,10 +81,6 @@ vi.mock('../src/session.js', () => {
return undefined;
}
getAttachmentHistoryForPersist() {
return undefined;
}
getOutput() {
return 'mock output';
}
+78 -3
View File
@@ -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);
});
});