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>
|
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);
|
||||
});
|
||||
});
|
||||
|
||||