Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/vitest.ci.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default defineConfig({
'test/perf-*.test.ts', // timing-sensitive perf benchmarks (flaky in CI)
'test/inline-rename.test.ts', // browser (Playwright)
'test/opencode-resize.test.ts', // browser (Playwright)
'test/terminal-viewport-ownership.test.ts', // browser (Playwright)
'test/webgl-fallback.test.ts', // browser (Playwright)
],
setupFiles: ['./test/setup.ts'],
Expand Down
2 changes: 1 addition & 1 deletion docs/archive/phase7-test-infrastructure-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ This avoids port conflicts entirely and runs fast. Only tests that need SSE or W
| 3223 | `test/routes/ralph-routes.test.ts` | Ralph API |
| 3224–3229 | Reserved | Future route tests |

Most tests should NOT need real ports — `app.inject()` is preferred. Verified: ports 3220–3229 are completely unused by existing tests (highest used port is 3211 in `opencode-resize.test.ts`).
Most tests should NOT need real ports — `app.inject()` is preferred. Verified: ports 3220–3229 are completely unused by existing tests (highest used port is 3213 in `terminal-viewport-ownership.test.ts`).

---

Expand Down
66 changes: 29 additions & 37 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2592,12 +2592,10 @@ export class Session extends EventEmitter {

/**
* 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.
* session. Presence and activity are deliberately separate: reconnecting a
* background desktop may register here, but only explicit interaction/input
* refreshes _lastDesktopActivityAt. This prevents an automatic page restore
* from taking sizing control away from an active phone.
*
* Deliberate tradeoff: claims are WS-only because only a socket has a
* liveness signal. A desktop degraded to the stateless HTTP resize fallback
Expand All @@ -2609,78 +2607,72 @@ export class Session extends EventEmitter {

/**
* A desktop sizing claim only blocks small-viewport resizes while the
* desktop is RECENTLY ACTIVE (claim registration or typed input within this
* desktop is RECENTLY ACTIVE (explicit sizing takeover or typed input within this
* window). An abandoned-but-connected desktop tab (left open at home, screen
* locked) must not hold a phone's view hostage: without this, the phone
* renders a desktop-width stream in a narrow xterm — mid-word wraps, tmux
* dot-fill, and Ink overdraw soup (the 0.9.8–0.9.12 mobile regression).
*/
private static readonly DESKTOP_CLAIM_IDLE_MS = 90_000;

/** Last evidence of a live desktop user (claim registered / typed input). */
/** Last evidence of explicit desktop interaction or typed input. */
private _lastDesktopActivityAt = 0;

/** Last desktop-typed dimensions, for re-asserting after a mobile override. */
private _lastDesktopDims: { cols: number; rows: number } | null = null;

/** True while a small viewport reflowed the pane past an idle desktop claim. */
private _mobileSizeOverride = false;

/** Register a live desktop sizing claim (see _desktopSizeClaims). */
/** Register live desktop presence without treating a reconnect as user activity. */
claimDesktopSizing(token: symbol): void {
this._desktopSizeClaims.add(token);
this._lastDesktopActivityAt = Date.now();
}

/** Release a desktop sizing claim when its connection goes away. */
releaseDesktopSizing(token: symbol): void {
this._desktopSizeClaims.delete(token);
}

/**
* Record desktop user activity (typed input over a claim-holding socket).
* If a phone reflowed the pane while the desktop was idle, the desktop
* layout is restored — "whoever is actively using the session wins".
*/
noteDesktopActivity(): void {
this._lastDesktopActivityAt = Date.now();
if (this._mobileSizeOverride && this._lastDesktopDims) {
this._mobileSizeOverride = false;
this.resize(this._lastDesktopDims.cols, this._lastDesktopDims.rows, { viewportType: 'desktop' });
}
}

/**
* 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 AND has been
* active within DESKTOP_CLAIM_IDLE_MS, resizes from small viewports
* active within DESKTOP_CLAIM_IDLE_MS, passive resizes from small viewports
* (mobile/tablet) are ignored — shrink AND grow would both reflow the
* desktop view. Once the desktop goes idle, a phone may take the pane (the
* desktop re-asserts its size on its next typed input via
* noteDesktopActivity). Without a desktop connected, small viewports
* control the PTY size freely.
* desktop view. A trusted interaction can explicitly take control. Once the
* desktop goes idle, a phone may also take the pane passively (the desktop
* re-asserts its size on its next interaction). 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; force?: boolean } = {}): void {
resize(
cols: number,
rows: number,
options: { viewportType?: ResizeViewportType; force?: boolean; takeControl?: boolean } = {}
): void {
const isDesktopViewport = options.viewportType === 'desktop';
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
if (options.viewportType === 'desktop') {
this._lastDesktopDims = { cols, rows };
this._lastDesktopActivityAt = Date.now();
const restoresMobileOverride = isDesktopViewport && this._mobileSizeOverride && options.takeControl === true;
if (isDesktopViewport) {
// Passive desktop restores must not displace a phone that explicitly
// took control of the shared PTY.
if (this._mobileSizeOverride && !options.takeControl) return;
if (options.takeControl) this._lastDesktopActivityAt = Date.now();
this._mobileSizeOverride = false;
}
if (isSmallViewport && options.takeControl) {
this._mobileSizeOverride = true;
}
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
if (!options.takeControl && Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
return;
}
this._mobileSizeOverride = true;
}
const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
if (this.ptyProcess && (dimsChanged || options.force)) {
if (this.ptyProcess && (dimsChanged || options.force || restoresMobileOverride)) {
this._ptyCols = cols;
this._ptyRows = rows;
if (this._mux && this._muxSession) {
Expand Down
30 changes: 25 additions & 5 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1316,7 +1316,7 @@ class CodemanApp {
if (!session) { this._showSoloSessionGone(); return; }
// Force re-select (handleInit cleared terminal state above).
this.activeSessionId = null;
this.selectSession(this.soloSessionId);
this.selectSession(this.soloSessionId, { takeControl: false });
const name = this.getSessionName(session) || 'Session';
const titleEl = document.getElementById('soloSessionTitle');
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
Expand Down Expand Up @@ -3184,9 +3184,9 @@ class CodemanApp {
try { restoreId = localStorage.getItem('codeman-active-session'); } catch {}
}
if (restoreId && this.sessions.has(restoreId)) {
this.selectSession(restoreId);
this.selectSession(restoreId, { takeControl: false });
} else {
this.selectSession(this.sessionOrder[0]);
this.selectSession(this.sessionOrder[0], { takeControl: false });
}
}
}
Expand Down Expand Up @@ -3621,6 +3621,11 @@ class CodemanApp {

handleSessionTabClick(event, sessionId) {
event?.preventDefault?.();
// Desktop activation must clear any mobile inline layout left by a prior
// emulated/touch viewport before measuring the terminal container.
if (typeof KeyboardHandler !== 'undefined') {
KeyboardHandler.resetForDesktopViewport?.();
}
// On touch with the keyboard hidden, blur the tapped tab so switching
// sessions doesn't pop the on-screen keyboard. Focus policy itself lives
// in selectSession via _shouldFocusTerminalForTabSwitch().
Expand Down Expand Up @@ -4043,6 +4048,11 @@ class CodemanApp {
return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
}

/**
* Select and render a session.
* Automatic restore/reconnect callers must pass takeControl:false so a
* background viewport cannot bypass server-side resize arbitration.
*/
async selectSession(sessionId, options = {}) {
// If this session is popped out into its own window, raise that window
// instead of showing it inline (focus-on-click for detached tabs). If we
Expand All @@ -4052,6 +4062,7 @@ class CodemanApp {
if (this._raiseDetached(sessionId)) return;
}
const forceReload = options?.forceReload === true;
const takeControl = options?.takeControl !== false;
if (this.activeSessionId === sessionId && !forceReload) return;
if (this.activeSessionId === sessionId && forceReload) {
this.terminalBufferCache?.delete(sessionId);
Expand Down Expand Up @@ -4204,7 +4215,16 @@ class CodemanApp {
// a small region with empty rows below the status bar.
// sendResize is a no-op on the server when dims haven't changed, so
// calling it every tab switch is cheap.
const dimsChanged = await this.sendResize(sessionId, { forceHttp: true }).catch(() => false);
const forceDesktopResize =
forceReload &&
(typeof MobileDetection === 'undefined' ||
!MobileDetection.getDeviceType ||
MobileDetection.getDeviceType() === 'desktop');
const dimsChanged = await this.sendResize(sessionId, {
force: forceDesktopResize,
refit: false,
...(takeControl ? { takeControl: true } : {}),
}).catch(() => false);
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
Expand Down Expand Up @@ -4395,7 +4415,7 @@ class CodemanApp {
// conversation. Stale Ink frames in the tailed buffer are a cosmetic
// annoyance that disappear on the user's next keypress; data loss is not
// acceptable. Do NOT re-introduce Ctrl+L here.
this.sendResize(sessionId);
this.sendResize(sessionId, takeControl ? { takeControl: true } : {});

// Defer secondary panel updates so they don't block the main thread
// after terminal content is already visible.
Expand Down
23 changes: 9 additions & 14 deletions src/web/public/mobile-handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,14 @@ const KeyboardHandler = {
}
},

/** Clear stale mobile keyboard state before a desktop viewport measures xterm. */
resetForDesktopViewport() {
if (typeof MobileDetection === 'undefined' || MobileDetection.getDeviceType?.() !== 'desktop') return;
this.keyboardVisible = false;
document.body.classList.remove('keyboard-visible');
this.resetLayout();
},

/** Called when keyboard appears */
onKeyboardShow() {
// Show keyboard accessory bar
Expand Down Expand Up @@ -474,20 +482,7 @@ const KeyboardHandler = {
_sendTerminalResize() {
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
try {
const dims = app.fitAddon.proposeDimensions();
if (dims) {
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, viewportType }),
}).catch(() => {});
}
app.sendResize(app.activeSessionId, { takeControl: true, refit: false }).catch(() => {});
} catch {}
},

Expand Down
Loading